From 762c72b4d08d74c0e073071b91db9a004d864bba Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 20:47:40 -0400 Subject: [PATCH] feat(ai): AI Data Access grants live on the node, not in localStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator: "the AI Data Access settings are not persistent through sessions, often turns them all off." They were stored in localStorage, which is scoped to an ORIGIN — and a node answers on several: LAN address, Tailscale address, .local, hostname. Granting Media over the LAN and returning over Tailscale showed every switch off again. Not reset: never set *there*. It also made a working content path look broken, because every scope silently returns nothing without a grant, so an ungranted permission is indistinguishable from an empty library — that is exactly what an empty films search turned out to be. The grant answers "what may the assistant read about THIS NODE", which is a property of the node, not of one browser at one address. New settings/ai_permissions.rs (same shape as session_policy: atomic temp+rename, sanitised on read and write, fails closed on a corrupt file — an unreadable grant file must never read as "everything allowed"). New ai.permissions.get / .set, absent from the unauthenticated allowlist so they require a session. Migration, not replacement: if this browser holds grants and the node holds none, the local set is pushed UP rather than wiped. Without that, upgrading would silently revoke the grants of everyone who set them before this change. The node still wins in every other direction, so a revocation made on one device takes effect everywhere — otherwise revoking would be impossible from a second device. Unknown category ids are stored verbatim rather than validated against a hardcoded list: a third copy of that list would silently drop a new category on upgrade. Storing a category grants nothing by itself — the broker checks before fetching and the node re-checks before answering (T-13-33). Hydration happens ONCE at broker start, not inside each permission gate: the gates are hot-path, and awaiting there adds an RPC to every content and context request. The first attempt did it per-gate and the existing broker tests caught it by failing on consumed mocks. Rust 7/7, store 18/18, broker 23/23. Co-Authored-By: Claude Opus 5 (1M context) --- core/archipelago/src/api/rpc/dispatcher.rs | 2 + .../src/api/rpc/system/handlers.rs | 42 ++++ .../src/settings/ai_permissions.rs | 184 ++++++++++++++++++ core/archipelago/src/settings/mod.rs | 1 + neode-ui/src/services/contextBroker.ts | 8 + .../stores/__tests__/aiPermissions.test.ts | 78 ++++++++ neode-ui/src/stores/aiPermissions.ts | 65 +++++++ .../views/settings/AIDataAccessSection.vue | 8 +- 8 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 core/archipelago/src/settings/ai_permissions.rs 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 @@