import type { Ref } from 'vue' import type { AIUIRequest, ArchyResponse, AIContextCategory, ArchyContextResponse, ArchyActionResponse, } from '@/types/aiui-protocol' import { useAIPermissionsStore } from '@/stores/aiPermissions' import { useAppStore } from '@/stores/app' import { useContainerStore, BUNDLED_APPS } from '@/stores/container' import { rpcClient } from '@/api/rpc-client' import { fileBrowserClient } from '@/api/filebrowser-client' /** * Context Broker — mediates all communication between AIUI (iframe) and Archy. * * AIUI sends context/action requests via postMessage. * The broker checks permissions, fetches data from Pinia stores, * sanitizes it (strips sensitive fields), and responds. */ export class ContextBroker { private iframe: Ref private allowedOrigin: string private listener: ((e: MessageEvent) => void) | null = null constructor(iframe: Ref, aiuiUrl: string) { this.iframe = iframe try { const url = new URL(aiuiUrl, window.location.origin) this.allowedOrigin = url.origin } catch { this.allowedOrigin = window.location.origin } } start() { this.listener = (e: MessageEvent) => this.handleMessage(e) window.addEventListener('message', this.listener) } stop() { if (this.listener) { window.removeEventListener('message', this.listener) this.listener = null } } sendPermissionsUpdate() { const perms = useAIPermissionsStore() this.postToIframe({ type: 'permissions:update', categories: perms.enabledCategories, }) } sendTheme() { this.postToIframe({ type: 'theme:response', theme: { accent: '#fb923c', mode: 'dark' }, }) } private handleMessage(event: MessageEvent) { if (event.origin !== this.allowedOrigin) return const msg = event.data as AIUIRequest if (!msg || typeof msg.type !== 'string') return switch (msg.type) { case 'ready': this.sendPermissionsUpdate() this.sendTheme() break case 'context:request': this.handleContextRequest(msg.id, msg.category, msg.query) break case 'action:request': this.handleActionRequest(msg.id, msg.action, msg.params) break case 'theme:request': this.sendTheme() break } } private async handleContextRequest(id: string, category: AIContextCategory, query?: string) { const perms = useAIPermissionsStore() if (!perms.isEnabled(category)) { this.postToIframe({ type: 'context:response', id, data: null, permitted: false, } satisfies ArchyContextResponse) return } const data = await this.fetchAndSanitize(category, query) this.postToIframe({ type: 'context:response', id, data, permitted: true, } satisfies ArchyContextResponse) } private handleActionRequest(id: string, action: string, params: Record) { const appStore = useAppStore() let success = false let error: string | undefined try { switch (action) { case 'navigate': if (params.path) { window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: params.path })) success = true } else { error = 'Missing path parameter' } break case 'open-app': case 'launch-app': if (params.appId) { const url = this.getAppUrl(params.appId) if (url) { window.dispatchEvent(new CustomEvent('aiui:open-app', { detail: params.appId })) success = true } else { error = `App "${params.appId}" not found or not running` } } else { error = 'Missing appId parameter' } break case 'install-app': if (params.appId && params.marketplaceUrl && params.version) { const packages = appStore.packages || {} const existing = packages[params.appId] if (existing && existing.state === 'installed') { this.postToIframe({ type: 'action:response', id, success: false, error: `${params.appId} is already installed`, } satisfies ArchyActionResponse) return } // Capture values for use in closure const appId = params.appId const marketplaceUrl = params.marketplaceUrl const version = params.version // Emit event for UI confirmation instead of installing directly window.dispatchEvent(new CustomEvent('aiui:install-request', { detail: { requestId: id, appId, marketplaceUrl, version }, })) { const broker = this const responseHandler = (e: Event) => { const detail = (e as CustomEvent).detail as { requestId: string; confirmed: boolean } if (detail.requestId !== id) return window.removeEventListener('aiui:install-response', responseHandler) if (detail.confirmed) { appStore.installPackage(appId, marketplaceUrl, version).then(() => { broker.postToIframe({ type: 'action:response', id, success: true, } satisfies ArchyActionResponse) }).catch((err: Error) => { broker.postToIframe({ type: 'action:response', id, success: false, error: err.message, } satisfies ArchyActionResponse) }) } else { broker.postToIframe({ type: 'action:response', id, success: false, error: 'User declined the installation', } satisfies ArchyActionResponse) } } window.addEventListener('aiui:install-response', responseHandler) setTimeout(() => { window.removeEventListener('aiui:install-response', responseHandler) }, 60000) } return } error = 'Missing required parameters (appId, marketplaceUrl, version)' break case 'search-web': if (params.query) { this.handleSearchAction(id, params.query) return } error = 'Missing query parameter' break case 'read-file': this.handleReadFileAction(id, params.path) return case 'tail-logs': this.handleTailLogsAction(id, params.appId, params.lines) return default: error = `Unknown action: ${action}` } } catch (e) { error = e instanceof Error ? e.message : 'Unknown error' } this.postToIframe({ type: 'action:response', id, success, error, } satisfies ArchyActionResponse) } private async handleSearchAction(id: string, query: string) { const appStore = useAppStore() const packages = appStore.packages || {} const searxng = packages['searxng'] if (!searxng || searxng.state !== 'installed' || searxng.installed?.status !== 'running') { this.postToIframe({ type: 'action:response', id, success: false, error: 'SearXNG is not installed or not running', } satisfies ArchyActionResponse) return } try { const response = await fetch(`/apps/searxng/search?q=${encodeURIComponent(query)}&format=json`) const results: unknown = await response.json() this.postToIframe({ type: 'action:response', id, success: true, data: results, } as ArchyActionResponse & { data: unknown }) } catch (err) { this.postToIframe({ type: 'action:response', id, success: false, error: err instanceof Error ? err.message : 'Search failed', } satisfies ArchyActionResponse) } } private getAppUrl(appId: string): string | null { const appStore = useAppStore() const packages = appStore.packages || {} const pkg = packages[appId] if (pkg?.installed?.status === 'running') { const ifaces = pkg.installed['interface-addresses'] if (ifaces) { const main = ifaces['main'] || Object.values(ifaces)[0] if (main?.['lan-address']) return main['lan-address'] } } const containerStore = useContainerStore() const containers = containerStore.containers const container = containers.find(c => c.name === appId || c.name === `archy-${appId}`) if (container?.lan_address) return container.lan_address const bundled = BUNDLED_APPS.find(a => a.id === appId) if (bundled?.ports?.[0]) return `/apps/${appId}/` return null } private async fetchAndSanitize(category: AIContextCategory, _query?: string): Promise { const appStore = useAppStore() switch (category) { case 'apps': return this.sanitizeApps(appStore) case 'system': return await this.sanitizeSystem(appStore) case 'network': return this.sanitizeNetwork(appStore) case 'wallet': return this.sanitizeWallet(appStore) case 'files': return this.sanitizeFiles() case 'bitcoin': return this.sanitizeBitcoin(appStore) case 'media': return this.sanitizeMedia(appStore) case 'search': return this.sanitizeSearch(appStore) case 'ai-local': return this.sanitizeAILocal(appStore) case 'notes': return this.sanitizeNotes() default: return null } } // T4: Enhanced apps with version, health, URL, web UI info private sanitizeApps(store: ReturnType): unknown { const packages = store.packages || {} const containerStore = useContainerStore() const apps = Object.entries(packages).map(([id, pkg]) => { const hasWebUI = !!pkg.manifest?.interfaces?.main?.ui const url = hasWebUI ? `/apps/${id}/` : null return { id, name: pkg.manifest?.title || id, version: pkg.manifest?.version || 'unknown', state: pkg.state || 'unknown', status: pkg.installed?.status || 'unknown', hasWebUI, url, } }) const bundledApps = containerStore.containers.map(c => ({ id: c.name, name: BUNDLED_APPS.find(b => b.id === c.name)?.name || c.name, state: c.state === 'running' ? 'installed' : 'stopped', status: c.state, hasWebUI: !!(BUNDLED_APPS.find(b => b.id === c.name)?.ports?.length), url: c.lan_address || null, })) return [...apps, ...bundledApps] } // T5: Real system metrics from RPC private async sanitizeSystem(store: ReturnType): Promise { const info = store.serverInfo const base = { version: info?.version || 'unknown', name: info?.name || 'Archipelago', } try { const [metrics, time] = await Promise.all([ rpcClient.call<{ cpu: number; disk: { used: number; total: number }; memory: { used: number; total: number } }>({ method: 'server.metrics' }), rpcClient.call<{ now: string; uptime: number }>({ method: 'server.time' }), ]) return { ...base, cpu: metrics.cpu, memory: { used: metrics.memory.used, total: metrics.memory.total }, disk: { used: metrics.disk.used, total: metrics.disk.total }, uptime: time.uptime, } } catch { return { ...base, status: 'metrics unavailable' } } } // T6: Network with peer count and Tor/Tailscale status private sanitizeNetwork(store: ReturnType): unknown { const info = store.serverInfo const containerStore = useContainerStore() const tailscale = containerStore.containers.find(c => c.name === 'tailscale') const hasTor = !!info?.['tor-address'] return { connected: store.isConnected, torConnected: hasTor, tailscaleActive: tailscale?.state === 'running', } } // T7: Bitcoin status + deep data from backend RPC private async sanitizeBitcoin(store: ReturnType): Promise { const packages = store.packages || {} const containerStore = useContainerStore() const btcPkg = packages['bitcoind'] || packages['bitcoin-core'] || packages['bitcoin'] const btcContainer = containerStore.containers.find(c => c.name === 'bitcoin-knots' || c.name === 'archy-bitcoin-knots' ) const isRunning = (btcPkg?.installed?.status === 'running') || (btcContainer?.state === 'running') if (!isRunning) { return { available: false, message: 'Bitcoin Core not running' } } try { const info = await rpcClient.call<{ block_height: number sync_progress: number chain: string difficulty: number mempool_size: number mempool_tx_count: number verification_progress: number }>({ method: 'bitcoin.getinfo' }) return { available: true, status: 'running', ...info } } catch { return { available: true, status: 'running', network: 'mainnet' } } } // T8: Media libraries from installed media apps private sanitizeMedia(store: ReturnType): unknown { const packages = store.packages || {} const mediaAppIds = ['plex', 'jellyfin', 'navidrome', 'nextcloud'] const libraries: { source: string; name: string; status: string }[] = [] for (const id of mediaAppIds) { const pkg = packages[id] if (pkg && (pkg.state === 'installed' || pkg.state === 'running' || pkg.state === 'stopped')) { libraries.push({ source: id, name: pkg.manifest?.title || id, status: pkg.state, }) } } if (libraries.length === 0) { return { available: false, libraries: [], message: 'No media apps installed. Install Plex or Jellyfin from the App Store.', } } return { available: true, libraries } } // T9: Files from FileBrowser private async sanitizeFiles(): Promise { try { if (!fileBrowserClient.isAuthenticated) { const ok = await fileBrowserClient.login() if (!ok) return { available: false, message: 'File browser authentication failed' } } const usage = await fileBrowserClient.getUsage() const items = await fileBrowserClient.listDirectory('/') const folders = items.filter(i => i.isDir).map(i => ({ name: i.name, path: i.path })) const recentFiles = items .filter(i => !i.isDir) .sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()) .slice(0, 10) .map(i => ({ name: i.name, path: i.path, size: i.size, modified: i.modified })) return { available: true, totalSize: usage.totalSize, folderCount: usage.folderCount, fileCount: usage.fileCount, folders, recentFiles, } } catch { return { available: false, message: 'File browser not reachable' } } } // T10: SearXNG search engine availability private sanitizeSearch(store: ReturnType): unknown { const packages = store.packages || {} const searxng = packages['searxng'] if (!searxng || searxng.state !== 'installed' || searxng.installed?.status !== 'running') { return { available: false } } return { available: true, engine: 'searxng', endpoint: '/apps/searxng/' } } // T11: Ollama local AI models private sanitizeAILocal(store: ReturnType): unknown { const packages = store.packages || {} const ollama = packages['ollama'] if (!ollama || ollama.state !== 'installed' || ollama.installed?.status !== 'running') { return { available: false } } return { available: true, models: [], message: 'Ollama is running. Query /api/tags for model list.', } } // T12: Wallet — LND deep data from backend RPC private async sanitizeWallet(store: ReturnType): Promise { const packages = store.packages || {} const containerStore = useContainerStore() const lndPkg = packages['lnd'] const lndContainer = containerStore.containers.find(c => c.name === 'lnd' || c.name === 'archy-lnd' ) const isRunning = (lndPkg?.installed?.status === 'running') || (lndContainer?.state === 'running') if (!isRunning) { return { available: false, message: 'Lightning (LND) not running' } } try { const info = await rpcClient.call<{ alias: string num_active_channels: number num_peers: number synced_to_chain: boolean block_height: number balance_sats: number channel_balance_sats: number pending_open_balance: number }>({ method: 'lnd.getinfo' }) return { available: true, status: 'running', ...info } } catch { return { available: true, status: 'running', message: 'LND is running but detailed info unavailable' } } } // T13: Notes/documents private sanitizeNotes(): unknown { return { available: false, documents: [], message: 'No note-taking apps installed', } } private static readonly ALLOWED_FILE_DIRS = [ '/var/lib/archipelago/', '/var/log/', '/opt/archipelago/', '/home/archipelago/', ] private static readonly SENSITIVE_PATH_PATTERNS = [ 'id_rsa', 'id_ed25519', 'private', 'secret', 'password', 'seed', '.env', 'wallet', 'macaroon', 'tls.key', 'tls.cert', 'credentials', 'keystore', 'mnemonic', ] private isPathAllowed(path: string): boolean { const normalized = path.replace(/\/+/g, '/').replace(/\.\.\//g, '') const inAllowedDir = ContextBroker.ALLOWED_FILE_DIRS.some(dir => normalized.startsWith(dir)) if (!inAllowedDir) return false const lower = normalized.toLowerCase() return !ContextBroker.SENSITIVE_PATH_PATTERNS.some(pattern => lower.includes(pattern)) } private async handleReadFileAction(id: string, path?: string) { const perms = useAIPermissionsStore() if (!perms.isEnabled('files')) { this.postToIframe({ type: 'action:response', id, success: false, error: 'File access not permitted' } satisfies ArchyActionResponse) return } if (!path) { this.postToIframe({ type: 'action:response', id, success: false, error: 'Missing path parameter' } satisfies ArchyActionResponse) return } if (!this.isPathAllowed(path)) { this.postToIframe({ type: 'action:response', id, success: false, error: 'Access denied: path is outside allowed directories or contains sensitive patterns' } satisfies ArchyActionResponse) return } try { if (!fileBrowserClient.isAuthenticated) { const ok = await fileBrowserClient.login() if (!ok) throw new Error('FileBrowser authentication failed') } const result = await fileBrowserClient.readFileAsText(path) this.postToIframe({ type: 'action:response', id, success: true, data: { content: result.content, truncated: result.truncated, size: result.size, path }, } as ArchyActionResponse) } catch (err) { this.postToIframe({ type: 'action:response', id, success: false, error: err instanceof Error ? err.message : 'Failed to read file', } satisfies ArchyActionResponse) } } private async handleTailLogsAction(id: string, appId?: string, linesStr?: string) { const perms = useAIPermissionsStore() if (!perms.isEnabled('apps')) { this.postToIframe({ type: 'action:response', id, success: false, error: 'App access not permitted' } satisfies ArchyActionResponse) return } if (!appId) { this.postToIframe({ type: 'action:response', id, success: false, error: 'Missing appId parameter' } satisfies ArchyActionResponse) return } const lines = Math.min(parseInt(linesStr || '50', 10) || 50, 200) try { const logs = await rpcClient.call({ method: 'container-logs', params: { app_id: appId, lines } }) const redactedLogs = logs.map(line => ContextBroker.redactLogLine(line)) this.postToIframe({ type: 'action:response', id, success: true, data: { appId, lines: redactedLogs, count: redactedLogs.length }, } as ArchyActionResponse) } catch (err) { this.postToIframe({ type: 'action:response', id, success: false, error: err instanceof Error ? err.message : 'Failed to fetch logs', } satisfies ArchyActionResponse) } } private static redactLogLine(line: string): string { // Redact RPC passwords (e.g., rpcpassword=xxx) let redacted = line.replace(/(?:rpcpassword|rpcauth|password|passwd|secret|token|apikey|api_key|macaroon)[\s]*[=:]\s*\S+/gi, '$&'.replace(/[=:]\s*\S+/, '=[REDACTED]')) // More targeted: key=value patterns redacted = redacted.replace(/((?:password|secret|token|apikey|api_key|macaroon|rpcpassword|rpcauth)\s*[=:]\s*)\S+/gi, '$1[REDACTED]') // Redact long hex strings (>32 chars, likely private keys) redacted = redacted.replace(/\b[0-9a-fA-F]{64,}\b/g, '[REDACTED_KEY]') // Redact base64 macaroon values (long base64 strings) redacted = redacted.replace(/\b[A-Za-z0-9+/]{64,}={0,2}\b/g, '[REDACTED_TOKEN]') return redacted } private postToIframe(msg: ArchyResponse) { if (!this.iframe.value?.contentWindow) return this.iframe.value.contentWindow.postMessage(msg, this.allowedOrigin) } }