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>
657 lines
22 KiB
TypeScript
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)
|
|
}
|
|
}
|