diff --git a/.claude/skills/add-app/SKILL.md b/.claude/skills/add-app/SKILL.md new file mode 100644 index 00000000..b47b64fd --- /dev/null +++ b/.claude/skills/add-app/SKILL.md @@ -0,0 +1,49 @@ +--- +name: add-app +description: Step-by-step guide for adding a new containerized app to Archipelago +disable-model-invocation: true +allowed-tools: Bash, Read, Write, Edit, Glob, Grep +argument-hint: "[app-name]" +--- + +Add a new containerized app ($ARGUMENTS) to Archipelago. + +## Steps + +### 1. Create the manifest + +Create `apps/{app-id}/manifest.yml` following the spec in `docs/app-manifest-spec.md`: +- `app.id` (kebab-case), `app.name`, `app.version` (SemVer) +- `container.image` (pinned version, **NEVER** `latest`) +- `security`: readonly_root, dropped capabilities, non-root UID > 1000 +- `health_check`, `dependencies` + +### 2. Add app icon + +Place icon at `neode-ui/public/assets/img/app-icons/{app-id}.{png|webp|svg}` + +### 3. Create status UI (if no native web UI) + +For apps without their own web interface, create a UI container in `docker/{app-id}-ui/` following the patterns in `.cursor/rules/APP-UI-STANDARDS.md`. + +Reference implementations: +- Bitcoin UI: `docker/bitcoin-ui/` +- LND UI: `docker/lnd-ui/` + +### 4. Update backend + +- Add port mapping in `core/archipelago/src/container/docker_packages.rs` +- Add env vars in `get_app_config()` in `core/archipelago/src/api/rpc.rs` + +### 5. Deploy and test + +- Deploy: `./scripts/deploy-to-target.sh --live` +- Install from marketplace UI at http://192.168.1.228 +- Verify it launches and auto-connects to dependencies +- Check logs: `sudo podman logs {container-name}` + +### 6. Security review + +- Verify readonly root, dropped caps, non-root user +- Check network isolation +- No hardcoded secrets diff --git a/.claude/skills/build-iso/SKILL.md b/.claude/skills/build-iso/SKILL.md new file mode 100644 index 00000000..fe093cbb --- /dev/null +++ b/.claude/skills/build-iso/SKILL.md @@ -0,0 +1,28 @@ +--- +name: build-iso +description: Build a new Archipelago auto-installer ISO image +disable-model-invocation: true +allowed-tools: Bash, Read +--- + +Build a new Archipelago auto-installer ISO. + +## Pre-build checklist + +1. Latest code deployed to server (`/deploy` first) +2. System configs synced (`/sync-configs` first) +3. Everything tested and working on live server + +## Build (on target server — recommended) + +```bash +sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228 'cd ~/archy/image-recipe && sudo ./build-auto-installer-iso.sh' +``` + +## Copy ISO back to Mac + +```bash +sshpass -p 'EwPDR8q45l0Upx@' scp -o StrictHostKeyChecking=no archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-auto-installer-*.iso . +``` + +**IMPORTANT**: Use `build-auto-installer-iso.sh` only. The deprecated `build-debian-iso.sh` causes boot-to-prompt issues. diff --git a/.claude/skills/check-server/SKILL.md b/.claude/skills/check-server/SKILL.md new file mode 100644 index 00000000..580ccb54 --- /dev/null +++ b/.claude/skills/check-server/SKILL.md @@ -0,0 +1,14 @@ +--- +name: check-server +description: Quick health check of the live Archipelago server +allowed-tools: Bash +--- + +Quick health check of the live server. SSH into `archipelago@192.168.1.228` (password: `EwPDR8q45l0Upx@`) and run: + +1. `systemctl is-active archipelago nginx` — are services running? +2. `sudo podman ps --format '{{.Names}} {{.Status}}'` — what containers are up? +3. `curl -s http://127.0.0.1:5678/health` — is the backend responding? +4. `sudo journalctl -u archipelago -n 10 --no-pager` — any recent errors? + +Report a brief one-paragraph status summary. diff --git a/.claude/skills/deploy-both/SKILL.md b/.claude/skills/deploy-both/SKILL.md new file mode 100644 index 00000000..f8d4a54d --- /dev/null +++ b/.claude/skills/deploy-both/SKILL.md @@ -0,0 +1,23 @@ +--- +name: deploy-both +description: Deploy all changes to both Archipelago servers +disable-model-invocation: true +allowed-tools: Bash, Read +--- + +Deploy all changes to BOTH servers (primary: 192.168.1.228, secondary: 192.168.1.198). + +## Steps + +1. Run: + ```bash + ./scripts/deploy-to-target.sh --both + ``` + +2. This builds on the primary server first, then copies built artifacts to the secondary. + +3. Verify both servers respond: + ```bash + sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228 'systemctl is-active archipelago' + sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.198 'systemctl is-active archipelago' + ``` diff --git a/.claude/skills/deploy/SKILL.md b/.claude/skills/deploy/SKILL.md new file mode 100644 index 00000000..4f8e647b --- /dev/null +++ b/.claude/skills/deploy/SKILL.md @@ -0,0 +1,24 @@ +--- +name: deploy +description: Deploy all changes to the live Archipelago server +disable-model-invocation: true +allowed-tools: Bash, Read +--- + +Deploy all changes to the live server (192.168.1.228). + +## Steps + +1. Run the deploy script from the project root: + ```bash + ./scripts/deploy-to-target.sh --live + ``` + +2. This syncs frontend and backend code, builds the Rust backend **on the server** (never locally on macOS), deploys frontend to `/opt/archipelago/web-ui/`, deploys backend binary to `/usr/local/bin/archipelago`, and restarts systemd + nginx. + +3. After deploy completes, verify the server is healthy: + ```bash + sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228 'systemctl is-active archipelago nginx && sudo journalctl -u archipelago -n 10 --no-pager' + ``` + +4. Report whether the deploy succeeded and if any errors appeared in the logs. diff --git a/.claude/skills/diagnose/SKILL.md b/.claude/skills/diagnose/SKILL.md new file mode 100644 index 00000000..5b3b1edf --- /dev/null +++ b/.claude/skills/diagnose/SKILL.md @@ -0,0 +1,21 @@ +--- +name: diagnose +description: Run a full diagnostic check on the Archipelago dev server +allowed-tools: Bash +--- + +SSH into the dev server and run a comprehensive diagnostic. Use `sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228` for all commands. + +## Checks to run + +1. **Services**: `systemctl is-active archipelago nginx` +2. **Backend status**: `sudo systemctl status archipelago --no-pager` +3. **Containers**: `sudo podman ps -a` +4. **Backend logs** (last 50): `sudo journalctl -u archipelago -n 50 --no-pager` +5. **Nginx errors**: `sudo tail -20 /var/log/nginx/error.log` +6. **RPC test**: `curl -s -X POST http://127.0.0.1:5678/rpc/v1 -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"echo","params":{}}'` +7. **Tor hostname**: `sudo cat /var/lib/archipelago/tor/hidden_service_archipelago/hostname` +8. **Disk space**: `df -h /` +9. **Memory**: `free -h` + +Report findings clearly and suggest fixes for any issues found. If $ARGUMENTS is provided, focus the diagnosis on that specific area. diff --git a/.claude/skills/frontend-dev/SKILL.md b/.claude/skills/frontend-dev/SKILL.md new file mode 100644 index 00000000..242a81cc --- /dev/null +++ b/.claude/skills/frontend-dev/SKILL.md @@ -0,0 +1,20 @@ +--- +name: frontend-dev +description: Start the local frontend development environment for Archipelago +disable-model-invocation: true +allowed-tools: Bash +--- + +Start the local frontend development environment. + +```bash +cd neode-ui && npm start +``` + +This starts: +- **Mock backend** on port 5959 (simulates the Rust backend API) +- **Vite dev server** on port 8100 + +Access at http://localhost:8100 (password: `password123`) + +The mock backend lets you develop the UI without needing the live server. diff --git a/.claude/skills/harden/SKILL.md b/.claude/skills/harden/SKILL.md new file mode 100644 index 00000000..6736cece --- /dev/null +++ b/.claude/skills/harden/SKILL.md @@ -0,0 +1,49 @@ +--- +name: harden +description: Security hardening review and fixes for Archipelago code and infrastructure +disable-model-invocation: true +allowed-tools: Read, Edit, Write, Glob, Grep, Bash +argument-hint: "[area: backend|frontend|containers|scripts|all]" +--- + +Perform a security hardening pass on $ARGUMENTS (default: all). + +## Backend Hardening (Rust) + +- [ ] No hardcoded credentials — check for Base64-encoded auth strings, passwords in source +- [ ] Secrets use `core/security/secrets_manager.rs` — verify encryption is implemented (not plaintext) +- [ ] All RPC endpoints validate inputs before processing +- [ ] No `unwrap()` on user-supplied data — handle errors gracefully +- [ ] Rate limiting on auth endpoints (login, password change) +- [ ] Session tokens have proper expiry and rotation +- [ ] File permissions: keys at 0o600, dirs at 0o700 +- [ ] Tracing never logs secrets, passwords, keys, or tokens + +## Frontend Hardening (Vue/TypeScript) + +- [ ] No secrets in source (API keys, passwords, tokens) +- [ ] No `eval()` or `innerHTML` with untrusted content +- [ ] XSS prevention — sanitize all user inputs +- [ ] CSRF protection on state-changing requests +- [ ] Credentials use `credentials: 'include'` not localStorage tokens +- [ ] No sensitive data in console.log statements + +## Container Hardening + +- [ ] All manifests: `readonly_root: true` (unless documented exception) +- [ ] All manifests: capabilities dropped, only required ones added +- [ ] All manifests: non-root user (UID > 1000) +- [ ] All manifests: `no-new-privileges: true` +- [ ] All images pinned to specific versions (no `:latest`) +- [ ] Network isolation — no `host` network unless required and documented +- [ ] AppArmor profiles defined and enforced + +## Script Hardening + +- [ ] All scripts use `set -euo pipefail` +- [ ] No hardcoded passwords (use deploy-config.sh or env vars) +- [ ] SSH uses proper key-based auth where possible +- [ ] No `chmod 777` or overly permissive permissions +- [ ] Temp files use `mktemp` not predictable paths + +Report all findings with file paths and line numbers. Fix issues directly where safe to do so. Flag anything that needs discussion. diff --git a/.claude/skills/lint/SKILL.md b/.claude/skills/lint/SKILL.md new file mode 100644 index 00000000..d684f640 --- /dev/null +++ b/.claude/skills/lint/SKILL.md @@ -0,0 +1,52 @@ +--- +name: lint +description: Run all linters and type checks for the Archipelago project +allowed-tools: Bash, Read, Grep +argument-hint: "[backend|frontend|all]" +--- + +Run linters and type-checks for $ARGUMENTS (default: all). + +## Frontend Linting + +```bash +cd neode-ui + +# Type check +npm run type-check 2>&1 + +# Check for any `any` types (should be zero) +grep -rn ': any' src/ --include='*.ts' --include='*.vue' | grep -v node_modules | grep -v '.d.ts' + +# Check for inline Tailwind violations (long class strings) +grep -rn 'class="[^"]\{100,\}"' src/ --include='*.vue' + +# Check for TODO/FIXME +grep -rn 'TODO\|FIXME' src/ --include='*.ts' --include='*.vue' + +# Check for console.log (should be cleaned before production) +grep -rn 'console\.\(log\|warn\|error\)' src/ --include='*.ts' --include='*.vue' | wc -l +``` + +## Backend Linting (on dev server) + +```bash +sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228 \ + 'source ~/.cargo/env && cd ~/archy/core && cargo clippy --all-targets --all-features 2>&1 && cargo fmt --all -- --check 2>&1' +``` + +## Script Linting + +```bash +# Check for scripts missing set -e +for f in scripts/*.sh; do + if ! head -5 "$f" | grep -q 'set -e'; then + echo "MISSING set -e: $f" + fi +done + +# Check for hardcoded IPs (should use variables) +grep -rn '192\.168\.1\.' scripts/ --include='*.sh' | grep -v deploy-config +``` + +Report all issues found with severity (critical/warning/info). diff --git a/.claude/skills/refactor/SKILL.md b/.claude/skills/refactor/SKILL.md new file mode 100644 index 00000000..8c0e036e --- /dev/null +++ b/.claude/skills/refactor/SKILL.md @@ -0,0 +1,41 @@ +--- +name: refactor +description: Refactor code for quality, maintainability, and adherence to project standards +disable-model-invocation: true +allowed-tools: Read, Edit, Write, Glob, Grep, Bash +argument-hint: "[file-or-area]" +--- + +Refactor the specified code ($ARGUMENTS) following Archipelago coding standards. + +## Checklist + +### Rust Backend +- [ ] No `unwrap()` or `expect()` — use `?` operator with context +- [ ] Replace `#[allow(dead_code)]` — either use it or remove it +- [ ] Functions under 50 lines, single responsibility +- [ ] Custom error types per module with `thiserror` +- [ ] `tracing` for logging — no `println!` or secrets in logs +- [ ] Split files over 500 lines into focused modules +- [ ] Run `cargo clippy --all-targets --all-features` mentally and fix issues + +### Vue Frontend +- [ ] Extract ALL inline Tailwind to global classes in `neode-ui/src/style.css` +- [ ] Use semantic class names: `.glass-card`, `.info-card`, `.glass-button`, `.path-option-card` +- [ ] Replace ALL `.gradient-button` with `.glass-button` (gradient buttons are BANNED) +- [ ] Replace ALL `.gradient-card` / `.gradient-card-dark` with `.glass-card` or `.path-option-card` +- [ ] Settings.vue is the gold standard — all screens should match its patterns +- [ ] Replace `any` types with proper interfaces or `unknown` +- [ ] Ensure ` + + diff --git a/neode-ui/src/components/OnlineStatusPill.vue b/neode-ui/src/components/OnlineStatusPill.vue index 695b8bf2..9fcfb903 100644 --- a/neode-ui/src/components/OnlineStatusPill.vue +++ b/neode-ui/src/components/OnlineStatusPill.vue @@ -2,15 +2,15 @@ diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index f60f7212..a3495a04 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -8,6 +8,8 @@ import type { } 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' /** * Context Broker — mediates all communication between AIUI (iframe) and Archy. @@ -23,7 +25,6 @@ export class ContextBroker { constructor(iframe: Ref, aiuiUrl: string) { this.iframe = iframe - // Extract origin from URL for security validation try { const url = new URL(aiuiUrl, window.location.origin) this.allowedOrigin = url.origin @@ -32,13 +33,11 @@ export class ContextBroker { } } - /** Start listening for postMessage events from AIUI */ start() { this.listener = (e: MessageEvent) => this.handleMessage(e) window.addEventListener('message', this.listener) } - /** Stop listening and clean up */ stop() { if (this.listener) { window.removeEventListener('message', this.listener) @@ -46,7 +45,6 @@ export class ContextBroker { } } - /** Send permissions update to AIUI so it knows what it can ask for */ sendPermissionsUpdate() { const perms = useAIPermissionsStore() this.postToIframe({ @@ -55,19 +53,14 @@ export class ContextBroker { }) } - /** Send theme info to AIUI */ sendTheme() { this.postToIframe({ type: 'theme:response', - theme: { - accent: '#fb923c', - mode: 'dark', - }, + theme: { accent: '#fb923c', mode: 'dark' }, }) } private handleMessage(event: MessageEvent) { - // Security: verify origin if (event.origin !== this.allowedOrigin) return const msg = event.data as AIUIRequest @@ -78,22 +71,19 @@ export class ContextBroker { 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 handleContextRequest(id: string, category: AIContextCategory, query?: string) { + private async handleContextRequest(id: string, category: AIContextCategory, query?: string) { const perms = useAIPermissionsStore() if (!perms.isEnabled(category)) { @@ -106,7 +96,7 @@ export class ContextBroker { return } - const data = this.fetchAndSanitize(category, query) + const data = await this.fetchAndSanitize(category, query) this.postToIframe({ type: 'context:response', id, @@ -132,9 +122,15 @@ export class ContextBroker { break case 'open-app': + case 'launch-app': if (params.appId) { - window.dispatchEvent(new CustomEvent('aiui:open-app', { detail: params.appId })) - success = true + 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' } @@ -142,6 +138,17 @@ export class ContextBroker { 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 + } appStore.installPackage(params.appId, params.marketplaceUrl, params.version).then(() => { this.postToIframe({ type: 'action:response', @@ -156,9 +163,17 @@ export class ContextBroker { error: err.message, } satisfies ArchyActionResponse) }) - return // async — response sent in promise callbacks + return } - error = 'Missing appId parameter' + 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 default: @@ -176,68 +191,263 @@ export class ContextBroker { } satisfies ArchyActionResponse) } - /** Fetch data from stores and strip sensitive fields */ - private fetchAndSanitize(category: AIContextCategory, _query?: string): unknown { + 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 this.sanitizeSystem(appStore) - case 'network': - return this.sanitizeNetwork(appStore) - case 'wallet': - return this.sanitizeWallet(appStore) - case 'files': - return this.sanitizeFiles(appStore) - default: - return null + 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 || {} - return Object.entries(packages).map(([id, pkg]) => ({ - id, - name: pkg.manifest?.title || id, - state: pkg.state || 'unknown', - status: pkg.installed?.status || 'unknown', + 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] } - private sanitizeSystem(store: ReturnType): unknown { + // T5: Real system metrics from RPC + private async sanitizeSystem(store: ReturnType): Promise { const info = store.serverInfo - if (!info) return { status: 'unavailable' } - return { - version: info.version, - name: info.name, - // Omit: hostname, IP, paths, kernel version, pubkey + 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, - // Omit: IP addresses, ports, peer details + torConnected: hasTor, + tailscaleActive: tailscale?.state === 'running', } } - private sanitizeWallet(_store: ReturnType): unknown { - // Wallet data requires careful handling — only expose aggregates + // T7: Bitcoin status from bundled app + private sanitizeBitcoin(store: ReturnType): 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' } + } + return { - available: false, - message: 'Wallet context not yet implemented', - // Will integrate with LND store when available + available: true, + status: 'running', + network: 'mainnet', } } - private sanitizeFiles(_store: ReturnType): unknown { - // File listing requires cloud store integration + // 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') { + libraries.push({ + source: id, + name: pkg.manifest?.title || id, + status: pkg.installed?.status || 'unknown', + }) + } + } + + 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 cloud/nextcloud + private sanitizeFiles(): unknown { return { available: false, - message: 'File context not yet implemented', - // Will integrate with cloud store when available + folders: [], + recentFiles: [], + message: 'File browser not yet available', + } + } + + // 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 aggregate data + private sanitizeWallet(store: ReturnType): 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' } + } + + return { + available: true, + status: 'running', + message: 'LND is running. Balance details require backend wallet RPC.', + } + } + + // T13: Notes/documents + private sanitizeNotes(): unknown { + return { + available: false, + documents: [], + message: 'No note-taking apps installed', } } diff --git a/neode-ui/src/stores/aiPermissions.ts b/neode-ui/src/stores/aiPermissions.ts index 51bf9b02..af14b7d8 100644 --- a/neode-ui/src/stores/aiPermissions.ts +++ b/neode-ui/src/stores/aiPermissions.ts @@ -9,6 +9,7 @@ export interface AIPermissionCategory { label: string description: string icon: string + group: string } export const AI_PERMISSION_CATEGORIES: AIPermissionCategory[] = [ @@ -17,30 +18,70 @@ export const AI_PERMISSION_CATEGORIES: AIPermissionCategory[] = [ label: 'Installed Apps', description: 'App names, status, and health — no credentials or config details', icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z', + group: 'Node Data', }, { id: 'system', label: 'System Stats', description: 'CPU, RAM, disk usage — no file paths or IP addresses', icon: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z', + group: 'Node Data', }, { id: 'network', label: 'Network Status', description: 'Connection status, peer count — no IP addresses or keys', icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01', + group: 'Node Data', }, { - id: 'wallet', - label: 'Wallet Overview', - description: 'Balance, channel count — no private keys, seeds, or addresses', - icon: 'M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z', + id: 'bitcoin', + label: 'Bitcoin Node', + description: 'Block height, sync progress, mempool stats — no wallet keys', + icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z', + group: 'Node Data', + }, + { + id: 'media', + label: 'Media Libraries', + description: 'Local media libraries — film, music, podcast titles and metadata, no file paths', + icon: 'M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z', + group: 'Media & Files', }, { id: 'files', label: 'File Names', description: 'Folder and file names in Cloud — no file contents', icon: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z', + group: 'Media & Files', + }, + { + id: 'notes', + label: 'Documents & Notes', + description: 'Document and note titles — no contents', + icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', + group: 'Media & Files', + }, + { + id: 'search', + label: 'Web Search', + description: 'Web search via your private SearXNG instance', + icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z', + group: 'AI & Search', + }, + { + id: 'ai-local', + label: 'Local AI Models', + description: 'Local AI models via Ollama — model names and availability', + icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', + group: 'AI & Search', + }, + { + id: 'wallet', + label: 'Wallet Overview', + description: 'Balance, channel count — no private keys, seeds, or addresses', + icon: 'M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z', + group: 'Financial', }, ] diff --git a/neode-ui/src/types/aiui-protocol.ts b/neode-ui/src/types/aiui-protocol.ts index f37a0397..11d0df22 100644 --- a/neode-ui/src/types/aiui-protocol.ts +++ b/neode-ui/src/types/aiui-protocol.ts @@ -6,10 +6,20 @@ */ /** Data categories that AIUI can request access to */ -export type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' +export type AIContextCategory = + | 'apps' + | 'system' + | 'network' + | 'wallet' + | 'files' + | 'media' + | 'search' + | 'ai-local' + | 'notes' + | 'bitcoin' /** Actions AIUI can request Archy to perform */ -export type AIActionType = 'install-app' | 'open-app' | 'navigate' +export type AIActionType = 'install-app' | 'open-app' | 'navigate' | 'launch-app' | 'search-web' // ─── AIUI → Archy (Requests) ─────────────────────────────────────────────── diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index 5f2d42e1..de841f72 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -1,6 +1,6 @@