Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/filebrowser-client', () => ({
|
||||
fileBrowserClient: {
|
||||
login: vi.fn(),
|
||||
isAuthenticated: false,
|
||||
getUsage: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
readFileAsText: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { ContextBroker } from '../contextBroker'
|
||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||
|
||||
describe('ContextBroker', () => {
|
||||
let broker: ContextBroker
|
||||
let iframeRef: Ref<HTMLIFrameElement | null>
|
||||
let mockPostMessage: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockPostMessage = vi.fn()
|
||||
iframeRef = ref<HTMLIFrameElement | null>({
|
||||
contentWindow: {
|
||||
postMessage: mockPostMessage,
|
||||
},
|
||||
} as unknown as HTMLIFrameElement)
|
||||
|
||||
broker = new ContextBroker(iframeRef, 'http://localhost:8100')
|
||||
})
|
||||
|
||||
it('creates with correct allowed origin', () => {
|
||||
expect(broker).toBeDefined()
|
||||
})
|
||||
|
||||
it('start registers message listener', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
broker.start()
|
||||
expect(addSpy).toHaveBeenCalledWith('message', expect.any(Function))
|
||||
broker.stop()
|
||||
addSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('stop removes message listener', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
broker.start()
|
||||
broker.stop()
|
||||
expect(removeSpy).toHaveBeenCalledWith('message', expect.any(Function))
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sendTheme sends theme response to iframe', () => {
|
||||
broker.sendTheme()
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
{ type: 'theme:response', theme: { accent: '#fb923c', mode: 'dark' } },
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('sendPermissionsUpdate sends enabled categories to iframe', () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.toggle('apps')
|
||||
perms.toggle('system')
|
||||
|
||||
broker.sendPermissionsUpdate()
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'permissions:update',
|
||||
categories: expect.arrayContaining(['apps', 'system']),
|
||||
}),
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not post when iframe has no contentWindow', () => {
|
||||
iframeRef.value = null
|
||||
broker.sendTheme()
|
||||
expect(mockPostMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('path validation', () => {
|
||||
// Access private method via prototype
|
||||
const isPathAllowed = (path: string) => {
|
||||
return (broker as unknown as { isPathAllowed: (p: string) => boolean }).isPathAllowed(path)
|
||||
}
|
||||
|
||||
it('allows paths in /var/lib/archipelago/', () => {
|
||||
expect(isPathAllowed('/var/lib/archipelago/bitcoin/data.db')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows paths in /var/log/', () => {
|
||||
expect(isPathAllowed('/var/log/syslog')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects paths outside allowed directories', () => {
|
||||
expect(isPathAllowed('/etc/shadow')).toBe(false)
|
||||
expect(isPathAllowed('/root/.ssh/id_rsa')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects paths with sensitive patterns', () => {
|
||||
expect(isPathAllowed('/var/lib/archipelago/secret.key')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/password.txt')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/wallet.dat')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/.env')).toBe(false)
|
||||
expect(isPathAllowed('/var/lib/archipelago/admin.macaroon')).toBe(false)
|
||||
})
|
||||
|
||||
it('strips path traversal sequences before checking', () => {
|
||||
// After stripping ../, path becomes /var/lib/archipelago/etc/passwd (still in allowed dir)
|
||||
expect(isPathAllowed('/var/lib/archipelago/../../etc/passwd')).toBe(true)
|
||||
// Path outside allowed dir is rejected even without traversal
|
||||
expect(isPathAllowed('/etc/passwd')).toBe(false)
|
||||
// Sensitive pattern inside allowed dir is still blocked
|
||||
expect(isPathAllowed('/var/lib/archipelago/../../etc/password')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('log redaction', () => {
|
||||
const redact = (line: string) => {
|
||||
return (ContextBroker as unknown as { redactLogLine: (l: string) => string }).redactLogLine(line)
|
||||
}
|
||||
|
||||
it('redacts password= patterns', () => {
|
||||
const result = redact('rpcpassword=mysecretpassword123')
|
||||
expect(result).not.toContain('mysecretpassword123')
|
||||
expect(result).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it('redacts token= patterns', () => {
|
||||
const result = redact('token=abc123def456')
|
||||
expect(result).not.toContain('abc123def456')
|
||||
})
|
||||
|
||||
it('redacts long hex strings (private keys)', () => {
|
||||
const hexKey = 'a'.repeat(64)
|
||||
const result = redact(`key: ${hexKey}`)
|
||||
expect(result).not.toContain(hexKey)
|
||||
expect(result).toContain('[REDACTED_KEY]')
|
||||
})
|
||||
|
||||
it('redacts long base64 strings (macaroons/tokens)', () => {
|
||||
const b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/AABB'
|
||||
const result = redact(`macaroon: ${b64}`)
|
||||
expect(result).toContain('[REDACTED')
|
||||
})
|
||||
|
||||
it('preserves non-sensitive log lines', () => {
|
||||
const line = '2026-03-11 INFO: Bitcoin block height: 841234'
|
||||
expect(redact(line)).toBe(line)
|
||||
})
|
||||
})
|
||||
|
||||
describe('content:request', () => {
|
||||
const callContentRequest = (id: string, kind: string, scope?: string) =>
|
||||
(
|
||||
broker as unknown as {
|
||||
handleContentRequest: (id: string, kind: string, scope?: string) => Promise<void>
|
||||
}
|
||||
).handleContentRequest(id, kind, scope)
|
||||
|
||||
it('refuses with permitted:false and makes no RPC call when neither media nor files is granted', async () => {
|
||||
await callContentRequest('req-denied', 'films', 'own')
|
||||
|
||||
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'content:push',
|
||||
id: 'req-denied',
|
||||
kind: 'films',
|
||||
permitted: false,
|
||||
films: [],
|
||||
songs: [],
|
||||
podcasts: [],
|
||||
images: [],
|
||||
},
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('adapts content.list-mine results into a content:push with permitted:true when media is granted', async () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ id: 'a', filename: 'movie.mp4', mime_type: 'video/mp4', size_bytes: 10, added_at: '2026-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
|
||||
await callContentRequest('req-1', 'films', 'own')
|
||||
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'content.list-mine' }))
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'content:push',
|
||||
id: 'req-1',
|
||||
kind: 'films',
|
||||
permitted: true,
|
||||
films: expect.arrayContaining([expect.objectContaining({ id: 'a' })]),
|
||||
}),
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('discards a stale in-flight response when a newer content:request has since started (out-of-order / AIUI-03 concurrency edge)', async () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
|
||||
let resolveFirst: (v: { items: unknown[] }) => void = () => {}
|
||||
const firstPromise = new Promise<{ items: unknown[] }>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
|
||||
vi.mocked(rpcClient.call)
|
||||
.mockImplementationOnce(() => firstPromise as unknown as Promise<never>)
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ id: 'fresh', filename: 'fresh.mp4', mime_type: 'video/mp4', size_bytes: 1, added_at: '2026-02-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
|
||||
const firstCall = callContentRequest('stale-req', 'films', 'own')
|
||||
const secondCall = callContentRequest('fresh-req', 'films', 'own')
|
||||
await secondCall
|
||||
|
||||
// The slow first request resolves AFTER the second one already
|
||||
// completed — its result must be discarded, not posted.
|
||||
resolveFirst({
|
||||
items: [
|
||||
{ id: 'old', filename: 'old.mp4', mime_type: 'video/mp4', size_bytes: 1, added_at: '2026-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
await firstCall
|
||||
|
||||
const staleCalls = mockPostMessage.mock.calls.filter(
|
||||
([msg]) => (msg as { type?: string; id?: string }).type === 'content:push' && (msg as { id?: string }).id === 'stale-req',
|
||||
)
|
||||
expect(staleCalls).toHaveLength(0)
|
||||
|
||||
const freshCalls = mockPostMessage.mock.calls.filter(
|
||||
([msg]) => (msg as { type?: string; id?: string }).type === 'content:push' && (msg as { id?: string }).id === 'fresh-req',
|
||||
)
|
||||
expect(freshCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('delivers BOTH results when two different kinds are requested together', async () => {
|
||||
// Regression: the guard used one counter for every kind, so requests for
|
||||
// different kinds cancelled each other. useArchy.ts init fires
|
||||
// content('all','own') then library('own') back to back and both sequence
|
||||
// numbers are assigned synchronously before either awaits — so the first
|
||||
// ALWAYS resolved stale and was dropped. Films, podcasts and own files
|
||||
// never reached the grid; only music ever did. Different kinds populate
|
||||
// different grids and cannot stale each other by definition.
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ items: [], tracks: [] } as never)
|
||||
|
||||
const films = callContentRequest('films-req', 'films', 'own')
|
||||
const library = callContentRequest('library-req', 'library', 'own')
|
||||
await Promise.all([films, library])
|
||||
|
||||
const pushed = mockPostMessage.mock.calls
|
||||
.map(([msg]) => msg as { type?: string; id?: string })
|
||||
.filter((m) => m.type === 'content:push')
|
||||
.map((m) => m.id)
|
||||
|
||||
expect(pushed).toContain('films-req')
|
||||
expect(pushed).toContain('library-req')
|
||||
})
|
||||
|
||||
it('still discards a stale response within the same kind and scope', async () => {
|
||||
// The guard's real purpose must survive being made per-key.
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ items: [] } as never)
|
||||
|
||||
await callContentRequest('older', 'films', 'own')
|
||||
await callContentRequest('newer', 'films', 'own')
|
||||
|
||||
// Both completed in order here, so both post; the ordering guarantee is
|
||||
// covered by the out-of-order test above. What this pins is that the key
|
||||
// is kind+scope, so a DIFFERENT scope does not collide with this one.
|
||||
const scoped = callContentRequest('peer-scope', 'films', 'peers')
|
||||
await scoped
|
||||
const pushed = mockPostMessage.mock.calls
|
||||
.map(([msg]) => msg as { type?: string; id?: string })
|
||||
.filter((m) => m.type === 'content:push')
|
||||
.map((m) => m.id)
|
||||
expect(pushed).toContain('peer-scope')
|
||||
})
|
||||
|
||||
// 13-11: kind: 'library' is the one addition this wave makes to the
|
||||
// discriminator — it resolves to music.list-tracks, not content.*,
|
||||
// since a library track carries real tag-extracted metadata
|
||||
// (artist/album/duration) ContentItem has no field for.
|
||||
it("kind: 'library' calls music.list-tracks (not content.list-mine) and adapts the result into the songs bucket", async () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
tracks: [
|
||||
{
|
||||
id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Song.flac' },
|
||||
title: 'Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
album_artist: 'Artist',
|
||||
duration_secs: 200,
|
||||
has_tags: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await callContentRequest('req-lib', 'library', 'own')
|
||||
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'music.list-tracks' }))
|
||||
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'content.list-mine' }))
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'content:push',
|
||||
id: 'req-lib',
|
||||
kind: 'library',
|
||||
permitted: true,
|
||||
films: [],
|
||||
songs: expect.arrayContaining([expect.objectContaining({ title: 'Song', artist: 'Artist', album: 'Album' })]),
|
||||
}),
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it("kind: 'library' degrades to an empty songs bucket (not a thrown error) when music.list-tracks fails", async () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('no index yet'))
|
||||
|
||||
await callContentRequest('req-lib-err', 'library', 'own')
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'content:push',
|
||||
id: 'req-lib-err',
|
||||
kind: 'library',
|
||||
permitted: true,
|
||||
films: [],
|
||||
songs: [],
|
||||
podcasts: [],
|
||||
images: [],
|
||||
}),
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptChatSurfaces', () => {
|
||||
const callAdapt = (surfaces: unknown) =>
|
||||
(
|
||||
broker as unknown as {
|
||||
adaptChatSurfaces: (s?: unknown) => { tool: string; scope?: string; bundle: { films: unknown[]; songs: unknown[]; podcasts: unknown[]; images: { id: string; url: string; locked: boolean }[] } }[] | undefined
|
||||
}
|
||||
).adaptChatSurfaces(surfaces)
|
||||
|
||||
// Live bug (archi-dev-box 2026-08-07): a purchased-scope chat surface
|
||||
// adapted the OwnedRpcItem wire shape as if it were ArchyContentItem —
|
||||
// id came out undefined, peerOnion was never passed, every URL was ''
|
||||
// — so three purchased images rendered as placeholders beside a correct
|
||||
// prose answer.
|
||||
it('purchased scope normalizes owned items and builds per-seller URLs', () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
const out = callAdapt([
|
||||
{
|
||||
tool: 'content_list',
|
||||
scope: 'purchased',
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
onion: 'peer-one.onion',
|
||||
content_id: 'cid-1',
|
||||
filename: 'signal-test.jpeg',
|
||||
mime_type: 'image/jpeg',
|
||||
size_bytes: 170000,
|
||||
paid_sats: 100,
|
||||
purchased_at: '2026-06-20T00:00:00Z',
|
||||
},
|
||||
{
|
||||
onion: 'peer-two.onion',
|
||||
content_id: 'cid-2',
|
||||
filename: 'got it!.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
size_bytes: 253000,
|
||||
paid_sats: 100,
|
||||
purchased_at: '2026-08-04T00:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(out).toHaveLength(1)
|
||||
const images = out![0]!.bundle.images
|
||||
expect(images).toHaveLength(2)
|
||||
// Real ids, real per-seller URLs, and already-paid items are unlocked.
|
||||
expect(images.map((i) => i.id)).toEqual(expect.arrayContaining(['cid-1', 'cid-2']))
|
||||
expect(images[0]!.url).toBe('/api/peer-content/peer-one.onion/cid-1')
|
||||
expect(images[1]!.url).toBe('/api/peer-content/peer-two.onion/cid-2')
|
||||
expect(images.every((i) => !i.locked)).toBe(true)
|
||||
})
|
||||
|
||||
it('peers scope adapts per-seller so every item URL carries its own onion', () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
const out = callAdapt([
|
||||
{
|
||||
tool: 'content_list',
|
||||
scope: 'peers',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'x', filename: 'a.jpg', mime_type: 'image/jpeg', size_bytes: 1, peer: 'seller-a.onion' },
|
||||
{ id: 'y', filename: 'b.jpg', mime_type: 'image/jpeg', size_bytes: 1, peer: 'seller-b.onion' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
const images = out![0]!.bundle.images
|
||||
expect(images.map((i) => i.url).sort()).toEqual([
|
||||
'/api/peer-content/seller-a.onion/x',
|
||||
'/api/peer-content/seller-b.onion/y',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('context gathering always answers', () => {
|
||||
it('responds when the gatherer never settles — the reported files hang', async () => {
|
||||
// A File Browser that accepts the connection and then says nothing:
|
||||
// the promise stays PENDING rather than rejecting, which is precisely
|
||||
// what sanitizeFiles' try/catch cannot see. Before the timeout, no
|
||||
// context:response was ever posted and AIUI waited out its own bridge
|
||||
// timeout instead — reported as "`files` context request times out".
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
;(fileBrowserClient.login as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
new Promise(() => {}),
|
||||
)
|
||||
|
||||
const pending = (
|
||||
broker as unknown as {
|
||||
handleContextRequest(id: string, category: string): Promise<void>
|
||||
}
|
||||
).handleContextRequest('req-hang', 'files')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
await pending
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'context:response',
|
||||
id: 'req-hang',
|
||||
data: null,
|
||||
permitted: true,
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a healthy category still returns its data, not null', async () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
|
||||
await (
|
||||
broker as unknown as {
|
||||
handleContextRequest(id: string, category: string): Promise<void>
|
||||
}
|
||||
).handleContextRequest('req-ok', 'apps')
|
||||
|
||||
const posted = mockPostMessage.mock.calls.find(
|
||||
(c) => (c[0] as { id?: string }).id === 'req-ok',
|
||||
)
|
||||
expect(posted).toBeDefined()
|
||||
expect((posted![0] as { permitted: boolean }).permitted).toBe(true)
|
||||
expect((posted![0] as { data: unknown }).data).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
// 13-08 Task 2: the trusted-chrome tool-confirmation flow (D-07/D-11).
|
||||
// The dialog text is RPC-fetched from the node (assistant.pending), drawn
|
||||
// by neode-ui outside the AIUI iframe, and the decision travels back over
|
||||
// the page's own authenticated RPC session (assistant.confirm-tool) — the
|
||||
// iframe is never in that path and cannot open, restyle or resolve it.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/filebrowser-client', () => ({
|
||||
fileBrowserClient: {
|
||||
login: vi.fn(),
|
||||
isAuthenticated: false,
|
||||
getUsage: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
readFileAsText: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { ContextBroker } from '../contextBroker'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
|
||||
|
||||
const PENDING_IMMICH = {
|
||||
req_id: 'confirm-1',
|
||||
nonce: 'node-minted-nonce-1',
|
||||
description: 'Restart the app "immich". Only "immich" is affected.',
|
||||
tool_name: 'app_restart',
|
||||
}
|
||||
|
||||
const PENDING_GITEA = {
|
||||
req_id: 'confirm-2',
|
||||
nonce: 'node-minted-nonce-2',
|
||||
description: 'Restart the app "gitea". Only "gitea" is affected.',
|
||||
tool_name: 'app_restart',
|
||||
}
|
||||
|
||||
describe('tool confirmation — ContextBroker half', () => {
|
||||
let broker: ContextBroker
|
||||
let iframeRef: Ref<HTMLIFrameElement | null>
|
||||
let mockPostMessage: ReturnType<typeof vi.fn>
|
||||
let confirmRequests: CustomEvent[]
|
||||
const captureRequest = (e: Event) => confirmRequests.push(e as CustomEvent)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
confirmRequests = []
|
||||
window.addEventListener('aiui:tool-confirm-request', captureRequest)
|
||||
|
||||
mockPostMessage = vi.fn()
|
||||
iframeRef = ref<HTMLIFrameElement | null>({
|
||||
contentWindow: { postMessage: mockPostMessage },
|
||||
} as unknown as HTMLIFrameElement)
|
||||
broker = new ContextBroker(iframeRef, 'http://localhost:8100')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
window.removeEventListener('aiui:tool-confirm-request', captureRequest)
|
||||
broker.stop()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** Mock a chat turn that stays in flight (the node is suspended on its
|
||||
* confirm gate) while assistant.pending reports `pending`. */
|
||||
function mockChatSuspendedWithPending(pending: typeof PENDING_IMMICH | null) {
|
||||
let releaseChat: (v: unknown) => void = () => {}
|
||||
vi.mocked(rpcClient.call).mockImplementation((opts: { method: string }) => {
|
||||
if (opts.method === 'assistant.chat') {
|
||||
return new Promise<unknown>((resolve) => {
|
||||
releaseChat = resolve
|
||||
}) as Promise<never>
|
||||
}
|
||||
if (opts.method === 'assistant.pending') {
|
||||
return Promise.resolve({ pending }) as Promise<never>
|
||||
}
|
||||
return Promise.resolve({}) as Promise<never>
|
||||
})
|
||||
return () => releaseChat({ text: 'done' })
|
||||
}
|
||||
|
||||
const startChat = () =>
|
||||
(
|
||||
broker as unknown as {
|
||||
handleChatRequest: (id: string, text: string) => Promise<void>
|
||||
}
|
||||
).handleChatRequest('chat-1', 'restart immich please')
|
||||
|
||||
it('a pending confirmation reported by the node opens the host dialog with the node-fetched description', async () => {
|
||||
const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH)
|
||||
|
||||
const chat = startChat()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
|
||||
expect(confirmRequests).toHaveLength(1)
|
||||
expect(confirmRequests[0]!.detail.reqId).toBe('confirm-1')
|
||||
expect(confirmRequests[0]!.detail.description).toBe(PENDING_IMMICH.description)
|
||||
|
||||
// The same pending action is never re-announced while it is open.
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(confirmRequests).toHaveLength(1)
|
||||
|
||||
releaseChat()
|
||||
await chat
|
||||
})
|
||||
|
||||
it('a confirmation that vanishes node-side is expired to the chrome so the dialog closes', async () => {
|
||||
let currentPending: typeof PENDING_IMMICH | null = PENDING_IMMICH
|
||||
let releaseChat: (v: unknown) => void = () => {}
|
||||
vi.mocked(rpcClient.call).mockImplementation((opts: { method: string }) => {
|
||||
if (opts.method === 'assistant.chat') {
|
||||
return new Promise<unknown>((resolve) => {
|
||||
releaseChat = resolve
|
||||
}) as Promise<never>
|
||||
}
|
||||
if (opts.method === 'assistant.pending') {
|
||||
return Promise.resolve({ pending: currentPending }) as Promise<never>
|
||||
}
|
||||
return Promise.resolve({}) as Promise<never>
|
||||
})
|
||||
const expired: CustomEvent[] = []
|
||||
const captureExpired = (e: Event) => expired.push(e as CustomEvent)
|
||||
window.addEventListener('aiui:tool-confirm-expired', captureExpired)
|
||||
|
||||
const chat = startChat()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(confirmRequests).toHaveLength(1)
|
||||
|
||||
// The node times the confirmation out: pending goes null mid-turn.
|
||||
currentPending = null
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(expired).toHaveLength(1)
|
||||
expect(expired[0]!.detail.reqId).toBe('confirm-1')
|
||||
|
||||
window.removeEventListener('aiui:tool-confirm-expired', captureExpired)
|
||||
releaseChat({ text: 'done' })
|
||||
await chat
|
||||
})
|
||||
|
||||
it('the chat turn ending expires any confirmation still on screen', async () => {
|
||||
const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH)
|
||||
const expired: CustomEvent[] = []
|
||||
const captureExpired = (e: Event) => expired.push(e as CustomEvent)
|
||||
window.addEventListener('aiui:tool-confirm-expired', captureExpired)
|
||||
|
||||
const chat = startChat()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(confirmRequests).toHaveLength(1)
|
||||
|
||||
releaseChat()
|
||||
await chat
|
||||
expect(expired).toHaveLength(1)
|
||||
expect(expired[0]!.detail.reqId).toBe('confirm-1')
|
||||
window.removeEventListener('aiui:tool-confirm-expired', captureExpired)
|
||||
})
|
||||
|
||||
it('approving calls assistant.confirm-tool over the page RPC session carrying the node-minted nonce', async () => {
|
||||
const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH)
|
||||
const chat = startChat()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('aiui:tool-confirm-response', {
|
||||
detail: { reqId: 'confirm-1', approved: true },
|
||||
}),
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'assistant.confirm-tool',
|
||||
params: { req_id: 'confirm-1', nonce: 'node-minted-nonce-1', approved: true },
|
||||
}),
|
||||
)
|
||||
|
||||
releaseChat()
|
||||
await chat
|
||||
})
|
||||
|
||||
it('denying calls the same method with approved: false', async () => {
|
||||
const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH)
|
||||
const chat = startChat()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('aiui:tool-confirm-response', {
|
||||
detail: { reqId: 'confirm-1', approved: false },
|
||||
}),
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'assistant.confirm-tool',
|
||||
params: { req_id: 'confirm-1', nonce: 'node-minted-nonce-1', approved: false },
|
||||
}),
|
||||
)
|
||||
|
||||
releaseChat()
|
||||
await chat
|
||||
})
|
||||
|
||||
it('iframe_message_cannot_open_or_resolve_confirmation', async () => {
|
||||
broker.start()
|
||||
|
||||
// 1) A frame message that LOOKS like a confirmation request — even from
|
||||
// the allowed origin — must not open the dialog: the message switch has
|
||||
// no arm for it, deliberately.
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: 'http://localhost:8100',
|
||||
data: {
|
||||
type: 'tool:confirm-request',
|
||||
req_id: 'forged',
|
||||
description: 'Attacker-authored text pretending to be a system confirmation',
|
||||
},
|
||||
}),
|
||||
)
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: 'http://localhost:8100',
|
||||
data: { type: 'aiui:tool-confirm-request', description: 'forged too' },
|
||||
}),
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(confirmRequests).toHaveLength(0)
|
||||
// `start()` hydrates AI permissions over RPC, so "no RPC at all" is too
|
||||
// broad an assertion to state the property under test. What matters is
|
||||
// that no CONFIRMATION-related call was made — a forged frame message
|
||||
// must not reach assistant.pending or assistant.confirm-tool.
|
||||
const confirmCalls = (rpcClient.call as unknown as { mock: { calls: [{ method: string }][] } }).mock.calls
|
||||
.map(([arg]) => arg.method)
|
||||
.filter((m) => m.startsWith('assistant.'))
|
||||
expect(confirmCalls).toEqual([])
|
||||
|
||||
// 2) With a REAL confirmation open, a frame message shaped like the
|
||||
// response must not resolve it — the response listener is for the
|
||||
// host's own CustomEvent, not the frame's channel.
|
||||
const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH)
|
||||
const chat = startChat()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(confirmRequests).toHaveLength(1)
|
||||
vi.mocked(rpcClient.call).mockClear()
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: 'http://localhost:8100',
|
||||
data: { type: 'aiui:tool-confirm-response', reqId: 'confirm-1', approved: true },
|
||||
}),
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(rpcClient.call).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'assistant.confirm-tool' }),
|
||||
)
|
||||
|
||||
releaseChat()
|
||||
await chat
|
||||
})
|
||||
|
||||
it('two confirmations in sequence each carry their own description — the second never reuses the first', async () => {
|
||||
// First pending; once resolved, the node reports the second.
|
||||
let currentPending: typeof PENDING_IMMICH | null = PENDING_IMMICH
|
||||
let releaseChat: (v: unknown) => void = () => {}
|
||||
vi.mocked(rpcClient.call).mockImplementation((opts: { method: string }) => {
|
||||
if (opts.method === 'assistant.chat') {
|
||||
return new Promise<unknown>((resolve) => {
|
||||
releaseChat = resolve
|
||||
}) as Promise<never>
|
||||
}
|
||||
if (opts.method === 'assistant.pending') {
|
||||
return Promise.resolve({ pending: currentPending }) as Promise<never>
|
||||
}
|
||||
if (opts.method === 'assistant.confirm-tool') {
|
||||
return Promise.resolve({ resolved: true }) as Promise<never>
|
||||
}
|
||||
return Promise.resolve({}) as Promise<never>
|
||||
})
|
||||
|
||||
const chat = startChat()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(confirmRequests).toHaveLength(1)
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('aiui:tool-confirm-response', {
|
||||
detail: { reqId: 'confirm-1', approved: false },
|
||||
}),
|
||||
)
|
||||
currentPending = PENDING_GITEA
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
|
||||
expect(confirmRequests).toHaveLength(2)
|
||||
expect(confirmRequests[1]!.detail.reqId).toBe('confirm-2')
|
||||
expect(confirmRequests[1]!.detail.description).toBe(PENDING_GITEA.description)
|
||||
expect(confirmRequests[1]!.detail.description).not.toBe(PENDING_IMMICH.description)
|
||||
|
||||
releaseChat({ text: 'done' })
|
||||
await chat
|
||||
})
|
||||
|
||||
it('no response event means no resolution — the action stays pending for the node to time out, never silently approved', async () => {
|
||||
const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH)
|
||||
const chat = startChat()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(confirmRequests).toHaveLength(1)
|
||||
vi.mocked(rpcClient.call).mockClear()
|
||||
|
||||
// The operator closes the dialog without deciding: nothing is sent.
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(rpcClient.call).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'assistant.confirm-tool' }),
|
||||
)
|
||||
|
||||
releaseChat()
|
||||
await chat
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool confirmation — ToolConfirmModal (trusted chrome)', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders as a direct child of document.body with a full-screen backdrop, showing the node-fetched text', () => {
|
||||
const wrapper = mount(ToolConfirmModal, {
|
||||
props: { show: true, description: PENDING_IMMICH.description },
|
||||
})
|
||||
|
||||
// Teleported: the overlay renders at <body> level, OUTSIDE the
|
||||
// component's own DOM subtree, so no ancestor transform (glass-panel
|
||||
// or otherwise) can trap its position: fixed. The test environment
|
||||
// globally stubs <Transition>, so tolerate that one wrapper between
|
||||
// the overlay and <body> — nothing else may sit in between.
|
||||
const overlay = document.body.querySelector('[data-testid="tool-confirm-overlay"]')
|
||||
expect(overlay).toBeTruthy()
|
||||
expect(wrapper.element.contains(overlay)).toBe(false)
|
||||
const parent = overlay?.parentElement
|
||||
const attachPoint =
|
||||
parent && parent.tagName.toLowerCase() === 'transition-stub'
|
||||
? parent.parentElement
|
||||
: parent
|
||||
expect(attachPoint).toBe(document.body)
|
||||
expect(overlay?.className).toContain('fixed')
|
||||
expect(overlay?.className).toContain('inset-0')
|
||||
|
||||
const backdrop = document.body.querySelector('[data-testid="tool-confirm-backdrop"]')
|
||||
expect(backdrop).toBeTruthy()
|
||||
expect(backdrop?.className).toContain('inset-0')
|
||||
|
||||
expect(document.body.textContent).toContain(PENDING_IMMICH.description)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('two sequential confirmations render their two different descriptions', async () => {
|
||||
const wrapper = mount(ToolConfirmModal, {
|
||||
props: { show: true, description: PENDING_IMMICH.description },
|
||||
})
|
||||
expect(document.body.textContent).toContain('immich')
|
||||
|
||||
await wrapper.setProps({ description: PENDING_GITEA.description })
|
||||
expect(document.body.textContent).toContain('gitea')
|
||||
expect(document.body.textContent).not.toContain('immich')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('Approve emits approve, Deny emits deny — and nothing else', async () => {
|
||||
const wrapper = mount(ToolConfirmModal, {
|
||||
props: { show: true, description: PENDING_IMMICH.description },
|
||||
})
|
||||
|
||||
const approve = document.body.querySelector(
|
||||
'[data-testid="tool-confirm-approve"]',
|
||||
) as HTMLButtonElement
|
||||
const deny = document.body.querySelector(
|
||||
'[data-testid="tool-confirm-deny"]',
|
||||
) as HTMLButtonElement
|
||||
expect(approve).toBeTruthy()
|
||||
expect(deny).toBeTruthy()
|
||||
|
||||
approve.click()
|
||||
expect(wrapper.emitted('approve')).toHaveLength(1)
|
||||
expect(wrapper.emitted('deny')).toBeUndefined()
|
||||
|
||||
deny.click()
|
||||
expect(wrapper.emitted('deny')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closing without a decision emits dismiss — never approve, never deny', async () => {
|
||||
const wrapper = mount(ToolConfirmModal, {
|
||||
props: { show: true, description: PENDING_IMMICH.description },
|
||||
})
|
||||
|
||||
const backdrop = document.body.querySelector(
|
||||
'[data-testid="tool-confirm-backdrop"]',
|
||||
) as HTMLElement
|
||||
backdrop.click()
|
||||
|
||||
expect(wrapper.emitted('dismiss')).toHaveLength(1)
|
||||
expect(wrapper.emitted('approve')).toBeUndefined()
|
||||
expect(wrapper.emitted('deny')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user