Files
archy/neode-ui/src/services/contextBroker.ts
T
archipelagoandClaude Opus 5 0ab9bdc7d3 feat(13-01): neode-ui carries chat over the existing origin-checked bridge
Adds chat:request/chat:response to the AIUI postMessage protocol
(AIUIChatRequest, ArchyChatResponse) and a handleChatRequest handler in
contextBroker.ts that calls assistant.chat over rpcClient on the page's own
session, then posts the result back through the existing postToIframe
helper. Reuses the broker's existing allowedOrigin guard unchanged — no
second postMessage channel, no relaxed origin check.

No permission category is threaded through the chat handler on purpose:
authority is resolved node-side from CallerScope (Task 1), and duplicating
a browser-side gate here would recreate the second, divergent security
model D-02 exists to prevent. tool-call is deliberately NOT added to
AIActionType — tool selection stays node-side by D-01/D-03.

On RPC failure the handler posts only the error message, never the raw
exception object.

Verified: contextBroker.test.ts (16/16) and chatAiuiEmbed.test.ts (7/7)
green; vue-tsc --noEmit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:37:41 -04:00

657 lines
22 KiB
TypeScript

import type { Ref } from 'vue'
import type {
AIUIRequest,
ArchyResponse,
AIContextCategory,
ArchyContextResponse,
ArchyActionResponse,
ArchyChatResponse,
} 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<HTMLIFrameElement | null>
private allowedOrigin: string
private listener: ((e: MessageEvent) => void) | null = null
constructor(iframe: Ref<HTMLIFrameElement | null>, 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
case 'chat:request':
this.handleChatRequest(msg.id, msg.text)
break
}
}
// Note: no permission category is threaded through here on purpose.
// Authority for a chat turn is resolved node-side from the RPC session's
// CallerScope (assistant.chat, core/archipelago/src/assistant/mod.rs) —
// duplicating a browser-side gate here would recreate the second,
// divergent security model D-02 exists to prevent. Do not "helpfully"
// add a permission check back into this handler.
private async handleChatRequest(id: string, text: string) {
try {
const result = await rpcClient.call<{ text: string }>({
method: 'assistant.chat',
params: { text },
})
this.postToIframe({
type: 'chat:response',
id,
success: true,
text: result.text,
} satisfies ArchyChatResponse)
} catch (err) {
this.postToIframe({
type: 'chat:response',
id,
success: false,
error: err instanceof Error ? err.message : 'Chat request failed',
} satisfies ArchyChatResponse)
}
}
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<string, string>) {
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<unknown> {
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<typeof useAppStore>): 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<typeof useAppStore>): Promise<unknown> {
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<typeof useAppStore>): 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<typeof useAppStore>): Promise<unknown> {
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<typeof useAppStore>): 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<unknown> {
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<typeof useAppStore>): 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<typeof useAppStore>): 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<typeof useAppStore>): Promise<unknown> {
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<string[]>({ 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)
}
}