Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/filebrowser-client', () => ({
|
||||
fileBrowserClient: {
|
||||
login: vi.fn(),
|
||||
isAuthenticated: false,
|
||||
getUsage: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
readFileAsText: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { ContextBroker } from '../contextBroker'
|
||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||
|
||||
describe('ContextBroker', () => {
|
||||
let broker: ContextBroker
|
||||
let iframeRef: Ref<HTMLIFrameElement | null>
|
||||
let mockPostMessage: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockPostMessage = vi.fn()
|
||||
iframeRef = ref<HTMLIFrameElement | null>({
|
||||
contentWindow: {
|
||||
postMessage: mockPostMessage,
|
||||
},
|
||||
} as unknown as HTMLIFrameElement)
|
||||
|
||||
broker = new ContextBroker(iframeRef, 'http://localhost:8100')
|
||||
})
|
||||
|
||||
it('creates with correct allowed origin', () => {
|
||||
expect(broker).toBeDefined()
|
||||
})
|
||||
|
||||
it('start registers message listener', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
broker.start()
|
||||
expect(addSpy).toHaveBeenCalledWith('message', expect.any(Function))
|
||||
broker.stop()
|
||||
addSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('stop removes message listener', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
broker.start()
|
||||
broker.stop()
|
||||
expect(removeSpy).toHaveBeenCalledWith('message', expect.any(Function))
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sendTheme sends theme response to iframe', () => {
|
||||
broker.sendTheme()
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
{ type: 'theme:response', theme: { accent: '#fb923c', mode: 'dark' } },
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('sendPermissionsUpdate sends enabled categories to iframe', () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.toggle('apps')
|
||||
perms.toggle('system')
|
||||
|
||||
broker.sendPermissionsUpdate()
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'permissions:update',
|
||||
categories: expect.arrayContaining(['apps', 'system']),
|
||||
}),
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not post when iframe has no contentWindow', () => {
|
||||
iframeRef.value = null
|
||||
broker.sendTheme()
|
||||
expect(mockPostMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('path validation', () => {
|
||||
// Access private method via prototype
|
||||
const isPathAllowed = (path: string) => {
|
||||
return (broker as unknown as { isPathAllowed: (p: string) => boolean }).isPathAllowed(path)
|
||||
}
|
||||
|
||||
it('allows paths in /var/lib/archipelago/', () => {
|
||||
expect(isPathAllowed('/var/lib/archipelago/bitcoin/data.db')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows paths in /var/log/', () => {
|
||||
expect(isPathAllowed('/var/log/syslog')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects paths outside allowed directories', () => {
|
||||
expect(isPathAllowed('/etc/shadow')).toBe(false)
|
||||
expect(isPathAllowed('/root/.ssh/id_rsa')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects paths with sensitive patterns', () => {
|
||||
expect(isPathAllowed('/var/lib/archipelago/secret.key')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/password.txt')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/wallet.dat')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/.env')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/admin.macaroon')).toBe(false)
|
||||
})
|
||||
|
||||
it('strips path traversal sequences before checking', () => {
|
||||
// After stripping ../, path becomes /var/lib/archipelago/etc/passwd (still in allowed dir)
|
||||
expect(isPathAllowed('/var/lib/archipelago/../../etc/passwd')).toBe(true)
|
||||
// Path outside allowed dir is rejected even without traversal
|
||||
expect(isPathAllowed('/etc/passwd')).toBe(false)
|
||||
// Sensitive pattern inside allowed dir is still blocked
|
||||
expect(isPathAllowed('/var/lib/archipelago/../../etc/password')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('log redaction', () => {
|
||||
const redact = (line: string) => {
|
||||
return (ContextBroker as unknown as { redactLogLine: (l: string) => string }).redactLogLine(line)
|
||||
}
|
||||
|
||||
it('redacts password= patterns', () => {
|
||||
const result = redact('rpcpassword=mysecretpassword123')
|
||||
expect(result).not.toContain('mysecretpassword123')
|
||||
expect(result).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it('redacts token= patterns', () => {
|
||||
const result = redact('token=abc123def456')
|
||||
expect(result).not.toContain('abc123def456')
|
||||
})
|
||||
|
||||
it('redacts long hex strings (private keys)', () => {
|
||||
const hexKey = 'a'.repeat(64)
|
||||
const result = redact(`key: ${hexKey}`)
|
||||
expect(result).not.toContain(hexKey)
|
||||
expect(result).toContain('[REDACTED_KEY]')
|
||||
})
|
||||
|
||||
it('redacts long base64 strings (macaroons/tokens)', () => {
|
||||
const b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/AABB'
|
||||
const result = redact(`macaroon: ${b64}`)
|
||||
expect(result).toContain('[REDACTED')
|
||||
})
|
||||
|
||||
it('preserves non-sensitive log lines', () => {
|
||||
const line = '2026-03-11 INFO: Bitcoin block height: 841234'
|
||||
expect(redact(line)).toBe(line)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,624 @@
|
||||
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<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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user