diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index b68d9028..473d9171 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -491,6 +491,8 @@ impl RpcHandler { "system.factory-reset" => self.handle_system_factory_reset(params).await, "auth.session-policy.get" => self.handle_session_policy_get().await, "auth.session-policy.set" => self.handle_session_policy_set(params).await, + "ai.permissions.get" => self.handle_ai_permissions_get().await, + "ai.permissions.set" => self.handle_ai_permissions_set(params).await, "system.settings.get" => self.handle_system_settings_get(params).await, "system.settings.set" => self.handle_system_settings_set(params).await, "system.kiosk-display.get" => self.handle_system_kiosk_display_get().await, diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index 80fab3a2..74ba6bff 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -1011,6 +1011,48 @@ impl RpcHandler { } } + /// ai.permissions.get — what the assistant may read about this node. + /// + /// Node-side because these grants were in per-origin localStorage, so they + /// vanished whenever the operator reached the node by a different address. + pub(in crate::api::rpc) async fn handle_ai_permissions_get( + &self, + ) -> Result { + let perms = crate::settings::ai_permissions::load(&self.config.data_dir).await; + Ok(serde_json::json!({ "granted": perms.granted })) + } + + /// ai.permissions.set — replace the grant set. + /// + /// Whole-set replacement rather than per-category toggles: the UI owns a + /// checkbox list and always knows the complete desired state, and a + /// toggle API would race itself when two tabs are open. The stored value + /// is echoed back so the caller sees exactly what was kept after + /// sanitising, which makes a dropped malformed entry visible instead of + /// silent. + pub(in crate::api::rpc) async fn handle_ai_permissions_set( + &self, + params: Option, + ) -> Result { + let params = params.unwrap_or(serde_json::json!({})); + let granted: Vec = params + .get("granted") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .ok_or_else(|| anyhow::anyhow!("Missing granted (array of category ids)"))?; + + let saved = crate::settings::ai_permissions::save( + &self.config.data_dir, + crate::settings::ai_permissions::AiPermissions { granted }, + ) + .await?; + Ok(serde_json::json!({ "granted": saved.granted })) + } + /// auth.session-policy.get — how long a login lasts on this node. pub(in crate::api::rpc) async fn handle_session_policy_get(&self) -> Result { let policy = crate::settings::session_policy::load(&self.config.data_dir).await; diff --git a/core/archipelago/src/settings/ai_permissions.rs b/core/archipelago/src/settings/ai_permissions.rs new file mode 100644 index 00000000..2e1452b1 --- /dev/null +++ b/core/archipelago/src/settings/ai_permissions.rs @@ -0,0 +1,184 @@ +//! What the assistant is allowed to read about this node. +//! +//! # Why this lives on the node and not in the browser +//! +//! These grants were stored in `localStorage`, which is scoped to an +//! **origin**. A node answers on several: its LAN address, its Tailscale +//! address, `.local`, and its hostname. Granting "Media" over the LAN +//! and returning over Tailscale showed every switch off again — not reset, +//! simply never set *there*. Operator-reported as "the AI Data Access settings +//! are not persistent through sessions, often turns them all off", and it made +//! a working content path look broken: every scope silently returns nothing +//! without a grant, so an ungranted permission is indistinguishable from an +//! empty library. +//! +//! The grant answers "what may the assistant read **about this node**". That is +//! a property of the node, not of one browser at one address, so the node is +//! where it belongs. Stored here it survives a cache clear, a new device, a +//! different browser, and any change of address. +//! +//! # Why the category list is not validated against a hardcoded set +//! +//! The authoritative list of categories lives in the UI +//! (`AI_PERMISSION_CATEGORIES`) and in the context broker's `fetchAndSanitize`. +//! Duplicating it here would create a third copy that silently drops a new +//! category on upgrade — the grant would round-trip through an older node and +//! come back missing. Unknown strings are stored verbatim and simply never +//! match a fetch, which fails closed. **Storing a category grants nothing on +//! its own**: the broker checks each category before it fetches, and the node +//! re-checks before it answers (T-13-33). This file records intent; it is not +//! the enforcement point. + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +const FILE_PATH: &str = "settings/ai_permissions.json"; + +/// Defence against a hand-edited or hostile file turning into unbounded +/// memory. Far above any real category count. +const MAX_CATEGORIES: usize = 64; +const MAX_CATEGORY_LEN: usize = 64; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiPermissions { + /// Categories the operator has granted. Empty means the assistant reads + /// nothing about this node, which is the default: a fresh node grants + /// nothing until asked. + #[serde(default)] + pub granted: Vec, +} + +impl AiPermissions { + /// Drop anything malformed and de-duplicate. Applied on both read and + /// write so a hand-edited file cannot produce a state the UI can never + /// display or undo. + pub fn sanitized(mut self) -> Self { + self.granted.retain(|c| { + !c.is_empty() + && c.len() <= MAX_CATEGORY_LEN + // Category ids are lowercase kebab (`ai-local`). Anything else + // is not something this node will ever match against. + && c.chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + }); + self.granted.sort(); + self.granted.dedup(); + self.granted.truncate(MAX_CATEGORIES); + self + } + + pub fn is_granted(&self, category: &str) -> bool { + self.granted.iter().any(|c| c == category) + } +} + +pub async fn load(data_dir: &Path) -> AiPermissions { + let path = data_dir.join(FILE_PATH); + match tokio::fs::read(&path).await { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map(AiPermissions::sanitized) + .unwrap_or_else(|e| { + // Fail closed. An unreadable grant file must not be treated as + // "everything allowed" — the assistant simply reads nothing + // until the operator sets it again. + tracing::warn!(error = %e, "AI permissions unreadable; granting nothing"); + AiPermissions::default() + }), + // Absent file is the ordinary first-run case, not an error. + Err(_) => AiPermissions::default(), + } +} + +pub async fn save(data_dir: &Path, perms: AiPermissions) -> anyhow::Result { + let perms = perms.sanitized(); + let path = data_dir.join(FILE_PATH); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + // Temp + rename so a crash mid-write cannot leave a truncated file that + // reads as "no grants" on the next boot. + let tmp = path.with_extension("json.tmp"); + tokio::fs::write(&tmp, serde_json::to_vec_pretty(&perms)?).await?; + tokio::fs::rename(&tmp, &path).await?; + Ok(perms) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fresh_node_grants_nothing() { + assert!(AiPermissions::default().granted.is_empty()); + assert!(!AiPermissions::default().is_granted("media")); + } + + #[test] + fn unknown_categories_survive_a_round_trip() { + // A newer UI may grant a category this binary has never heard of. + // Dropping it would silently revoke the grant on downgrade/upgrade. + let p = AiPermissions { + granted: vec!["media".into(), "some-future-category".into()], + } + .sanitized(); + assert!(p.is_granted("some-future-category")); + } + + #[test] + fn malformed_entries_are_dropped_not_stored() { + let p = AiPermissions { + granted: vec![ + "media".into(), + "".into(), + "UPPER".into(), + "has space".into(), + "../../etc/passwd".into(), + "x".repeat(500), + ], + } + .sanitized(); + assert_eq!(p.granted, vec!["media".to_string()]); + } + + #[test] + fn duplicates_collapse() { + let p = AiPermissions { + granted: vec!["media".into(), "media".into(), "files".into()], + } + .sanitized(); + assert_eq!(p.granted, vec!["files".to_string(), "media".to_string()]); + } + + #[tokio::test] + async fn absent_file_reads_as_no_grants_rather_than_an_error() { + let dir = tempfile::tempdir().unwrap(); + assert!(load(dir.path()).await.granted.is_empty()); + } + + #[tokio::test] + async fn a_corrupt_file_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_PATH); + tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap(); + tokio::fs::write(&path, b"{ not json").await.unwrap(); + // The dangerous failure would be defaulting to "all granted". + assert!(load(dir.path()).await.granted.is_empty()); + } + + #[tokio::test] + async fn saved_grants_survive_a_reload() { + let dir = tempfile::tempdir().unwrap(); + save( + dir.path(), + AiPermissions { + granted: vec!["media".into(), "files".into()], + }, + ) + .await + .unwrap(); + let back = load(dir.path()).await; + assert!(back.is_granted("media")); + assert!(back.is_granted("files")); + assert!(!back.is_granted("wallet")); + } +} diff --git a/core/archipelago/src/settings/mod.rs b/core/archipelago/src/settings/mod.rs index d2eb985a..db3bd5a5 100644 --- a/core/archipelago/src/settings/mod.rs +++ b/core/archipelago/src/settings/mod.rs @@ -4,5 +4,6 @@ //! call sites (deep in the transport / RPC / ingest stacks) don't need //! to thread a data_dir or Arc through the entire call graph. +pub mod ai_permissions; pub mod session_policy; pub mod transport; diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index 5cf1bda6..0aacd118 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -139,6 +139,14 @@ export class ContextBroker { } start() { + // Grants live on the NODE, not in this browser. localStorage is per-origin + // and a node answers on several addresses (LAN, Tailscale, .local), + // so a perfectly granted permission can read as denied at a second origin. + // Reconcile ONCE here rather than inside each permission gate: the gates + // are on the hot path, and an await there would add an RPC to every + // content and context request. + void useAIPermissionsStore().hydrate() + this.listener = (e: MessageEvent) => this.handleMessage(e) window.addEventListener('message', this.listener) } diff --git a/neode-ui/src/stores/__tests__/aiPermissions.test.ts b/neode-ui/src/stores/__tests__/aiPermissions.test.ts index 12cf3abb..2f848e24 100644 --- a/neode-ui/src/stores/__tests__/aiPermissions.test.ts +++ b/neode-ui/src/stores/__tests__/aiPermissions.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '../aiPermissions' +import { rpcClient } from '@/api/rpc-client' + +vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) const STORAGE_KEY = 'archipelago-ai-permissions' @@ -103,4 +106,79 @@ describe('useAIPermissionsStore', () => { expect(cat.group).toBeTruthy() } }) + + describe('node-side persistence (grants are a property of the node, not a browser)', () => { + it('MIGRATES local grants up when the node has none — never silently revokes them', async () => { + // Everyone who granted permissions before this change has them only in + // localStorage. An empty node must not wipe that on first hydrate. + localStorage.setItem(STORAGE_KEY, JSON.stringify(['media', 'files'])) + const store = useAIPermissionsStore() + vi.mocked(rpcClient.call).mockResolvedValueOnce({ granted: [] } as never) + + await store.hydrate() + + expect(store.isEnabled('media')).toBe(true) + expect(store.isEnabled('files')).toBe(true) + expect(vi.mocked(rpcClient.call).mock.calls.some( + ([a]) => (a as { method?: string }).method === 'ai.permissions.set', + )).toBe(true) + }) + + it('adopts the node grants on a browser that has none — the new-device case', async () => { + const store = useAIPermissionsStore() + vi.mocked(rpcClient.call).mockResolvedValueOnce({ granted: ['wallet'] } as never) + + await store.hydrate() + + expect(store.isEnabled('wallet')).toBe(true) + // and it is cached locally so the next paint is instant + expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')).toContain('wallet') + }) + + it('lets the node REVOKE a grant this browser still remembers', async () => { + // The node is authoritative. A revocation made elsewhere must win, or + // revoking would be impossible from any second device. + localStorage.setItem(STORAGE_KEY, JSON.stringify(['media', 'wallet'])) + const store = useAIPermissionsStore() + vi.mocked(rpcClient.call).mockResolvedValueOnce({ granted: ['media'] } as never) + + await store.hydrate() + + expect(store.isEnabled('media')).toBe(true) + expect(store.isEnabled('wallet')).toBe(false) + }) + + it('keeps local grants when the node is unreachable rather than blanking them', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(['media'])) + const store = useAIPermissionsStore() + vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('offline')) + + await store.hydrate() + + expect(store.isEnabled('media')).toBe(true) + expect(store.hydrated).toBe(true) + }) + + it('ignores categories the node reports that this build does not know', async () => { + const store = useAIPermissionsStore() + vi.mocked(rpcClient.call).mockResolvedValueOnce({ granted: ['media', 'not-a-category'] } as never) + + await store.hydrate() + + expect(store.isEnabled('media')).toBe(true) + expect(store.enabledCategories).not.toContain('not-a-category') + }) + + it('pushes every toggle to the node', async () => { + const store = useAIPermissionsStore() + vi.mocked(rpcClient.call).mockResolvedValue({ granted: [] } as never) + + store.toggle('media') + await Promise.resolve() + + expect(vi.mocked(rpcClient.call).mock.calls.some( + ([a]) => (a as { method?: string }).method === 'ai.permissions.set', + )).toBe(true) + }) + }) }) diff --git a/neode-ui/src/stores/aiPermissions.ts b/neode-ui/src/stores/aiPermissions.ts index 85c35109..074195a6 100644 --- a/neode-ui/src/stores/aiPermissions.ts +++ b/neode-ui/src/stores/aiPermissions.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { AIContextCategory } from '@/types/aiui-protocol' +import { rpcClient } from '@/api/rpc-client' const STORAGE_KEY = 'archipelago-ai-permissions' @@ -86,7 +87,66 @@ export const AI_PERMISSION_CATEGORIES: AIPermissionCategory[] = [ ] export const useAIPermissionsStore = defineStore('aiPermissions', () => { + // Seeded from localStorage so the toggles paint immediately, then reconciled + // with the node in hydrate(). The node is authoritative. const enabled = ref>(loadFromStorage()) + const hydrated = ref(false) + + /** + * Reconcile with the node, which is where these grants actually live. + * + * They used to live ONLY in localStorage, which is scoped to an origin — and + * a node answers on several (LAN address, Tailscale address, .local, + * hostname). Granting over one and returning by another showed every switch + * off again: not reset, just never set *there*. Reported as "the AI Data + * Access settings are not persistent through sessions", and it made a working + * content path look broken, because every scope silently returns nothing + * without a grant. + * + * Migration, not replacement: if this browser holds grants and the node holds + * none, the local set is pushed UP rather than being wiped by an empty node. + * That covers everyone who granted permissions before this change — without + * it, upgrading would silently revoke them. The reverse (node has grants, + * browser does not) is the normal case on a new device and the node wins. + */ + async function hydrate(): Promise { + try { + const res = await rpcClient.call<{ granted?: string[] }>({ method: 'ai.permissions.get' }) + const remote = new Set( + (res.granted ?? []).filter((c): c is AIContextCategory => + AI_PERMISSION_CATEGORIES.some(cat => cat.id === c), + ), + ) + + if (remote.size === 0 && enabled.value.size > 0) { + await pushToNode() + } else { + enabled.value = remote + save() + } + } catch (e) { + // Offline, or a node too old to know the method: keep whatever + // localStorage had. Degrading to the old behaviour is strictly better + // than blanking the operator's grants because a request failed. + if (import.meta.env.DEV) console.warn('AI permissions: node unreachable, using local', e) + } finally { + hydrated.value = true + } + } + + async function pushToNode(): Promise { + try { + await rpcClient.call({ + method: 'ai.permissions.set', + params: { granted: [...enabled.value] }, + }) + } catch (e) { + // The local write already happened, so the UI stays consistent with what + // the operator just clicked; it will be pushed again on the next change + // or the next hydrate(). + if (import.meta.env.DEV) console.warn('AI permissions: failed to persist to node', e) + } + } function loadFromStorage(): Set { try { @@ -119,16 +179,19 @@ export const useAIPermissionsStore = defineStore('aiPermissions', () => { // Trigger reactivity enabled.value = new Set(enabled.value) save() + void pushToNode() } function enableAll() { enabled.value = new Set(AI_PERMISSION_CATEGORIES.map(c => c.id)) save() + void pushToNode() } function disableAll() { enabled.value = new Set() save() + void pushToNode() } const enabledCategories = computed(() => [...enabled.value]) @@ -137,6 +200,8 @@ export const useAIPermissionsStore = defineStore('aiPermissions', () => { return { enabled, + hydrated, + hydrate, isEnabled, toggle, enableAll, diff --git a/neode-ui/src/views/settings/AIDataAccessSection.vue b/neode-ui/src/views/settings/AIDataAccessSection.vue index 90b7fffb..3572bb11 100644 --- a/neode-ui/src/views/settings/AIDataAccessSection.vue +++ b/neode-ui/src/views/settings/AIDataAccessSection.vue @@ -1,5 +1,5 @@