feat(ai): AI Data Access grants live on the node, not in localStorage

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, <host>.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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 20:47:40 -04:00
co-authored by Claude Opus 5
parent 9e86d18921
commit 762c72b4d0
8 changed files with 387 additions and 1 deletions
+8
View File
@@ -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, <host>.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)
}
@@ -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)
})
})
})
+65
View File
@@ -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<Set<AIContextCategory>>(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, <host>.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<void> {
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<void> {
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<AIContextCategory> {
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,
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
@@ -7,6 +7,12 @@ import ToggleSwitch from '@/components/ToggleSwitch.vue'
const { t } = useI18n()
const aiPermissions = useAIPermissionsStore()
// Grants live on the node, not in this browser's localStorage — reconcile on
// open so the switches show the node's truth rather than whatever this origin
// happens to remember. Without this the same node shows different settings at
// its LAN address and its Tailscale address.
onMounted(() => { void aiPermissions.hydrate() })
const aiCategoryGroups = computed(() => {
const groups: { label: string; items: typeof AI_PERMISSION_CATEGORIES }[] = []
for (const cat of AI_PERMISSION_CATEGORIES) {