wip(13-08): checkpoint before operator session restart — Task 1 GREEN (28/28), Task 2 in progress

Executor stopped deliberately for a session restart (bypass-permissions relaunch).
Executor's final report: 'cargo test assistant confirm-gate suite 28/28 green,
individual nonce test passes; committing Task 1 next — first verify the
tools.rs/grants.rs/backends diffs are formatting-only.'

Task 1 (D-07/D-11 confirm gate, backend) is implemented and test-green but this
checkpoint is verbatim-uncommitted-state, NOT the reviewed atomic Task 1 commit:
continuation executor should verify diffs, then reset --soft or commit-on-top
into proper feat(13-08) task commits. Task 2 (ToolConfirmModal.vue trusted
chrome, Chat.vue + contextBroker.ts wiring) is partially built, tests written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 11:34:31 -04:00
co-authored by Claude Fable 5
parent db11c625c8
commit fc09d7a292
11 changed files with 1011 additions and 53 deletions
@@ -0,0 +1,134 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
data-testid="tool-confirm-overlay"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="dismiss"
>
<div
data-testid="tool-confirm-backdrop"
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
></div>
<div ref="modalRef" @click.stop class="glass-card p-6 max-w-md w-full relative z-10">
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">Approve this action?</h3>
<button
@click="dismiss"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!--
The description is node-authored: fetched by the host page over
its own authenticated RPC session (assistant.pending), never
received from the AIUI iframe and never model text. Plain
interpolation only peer-influenced argument values must render
as inert text, so the raw-HTML directive is banned in this file.
There is deliberately NO code path in this component that reads
from the frame's message channel.
-->
<div class="bg-black/20 rounded-xl border border-white/10 p-4 mb-4">
<p class="text-white text-sm leading-relaxed whitespace-pre-wrap">{{ description }}</p>
</div>
<p class="text-white/40 text-xs mb-4">
The assistant asked to do this. Nothing happens unless you approve — closing this
window decides nothing, and the request expires on its own.
</p>
<div class="flex gap-3">
<button
data-testid="tool-confirm-deny"
@click="deny"
class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium"
>
Deny
</button>
<button
data-testid="tool-confirm-approve"
@click="approve"
class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium text-orange-400 border-orange-400/30"
>
Approve
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const props = defineProps<{
show: boolean
description: string
}>()
const emit = defineEmits<{
approve: []
deny: []
/** Closed without a decision: nothing is sent anywhere — the node's own
* timeout declines the pending action. Never treated as an approval. */
dismiss: []
}>()
const modalRef = ref<HTMLElement | null>(null)
useModalKeyboard(
modalRef,
computed(() => props.show),
() => emit('dismiss'),
)
function approve() {
emit('approve')
}
function deny() {
emit('deny')
}
function dismiss() {
emit('dismiss')
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
</style>
@@ -0,0 +1,358 @@
// 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((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('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)
expect(rpcClient.call).not.toHaveBeenCalled()
// 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((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()
})
})
+136
View File
@@ -49,6 +49,19 @@ function normalizeOwnedItem(owned: OwnedRpcItem): ArchyContentItem {
}
}
/** Wire shape of `assistant.pending`'s response payload: the node-authored
* description and the node-minted nonce for the one pending destructive
* action (13-08, D-07/D-11). The description is drawn by the HOST chrome
* (ToolConfirmModal.vue), never by AIUI — and it reaches the page over the
* authenticated RPC session only, never over the iframe's postMessage
* channel, so the frame cannot forge or restyle it. */
interface PendingToolConfirm {
req_id: string
nonce: string
description: string
tool_name?: string
}
function emptyBundle(): ArchyContentBundle {
return { films: [], songs: [], podcasts: [] }
}
@@ -82,6 +95,24 @@ export class ContextBroker {
* of posted, so a slow response can never overwrite fresher grid data. */
private contentRequestSeq = 0
/** 13-08: how often the broker asks the node for a pending destructive-
* tool confirmation while a chat turn is in flight. The node's loop is
* suspended on its confirm gate during that window, so this poll is what
* turns "the node is waiting on a human" into a visible dialog. */
private static readonly CONFIRM_POLL_MS = 1200
/** How long the one-shot aiui:tool-confirm-response listener stays armed
* before being cleaned up — slightly beyond the node's own CONFIRM_TIMEOUT
* (120s), after which the node has already declined the action itself. */
private static readonly CONFIRM_LISTENER_TTL_MS = 130_000
private confirmPollTimer: ReturnType<typeof setInterval> | null = null
/** Chat turns currently in flight — polling runs while > 0. */
private activeChatTurns = 0
/** req_ids already announced to the host chrome, so one pending action is
* dialogued exactly once no matter how many polls observe it. */
private announcedConfirmReqIds = new Set<string>()
constructor(iframe: Ref<HTMLIFrameElement | null>, aiuiUrl: string) {
this.iframe = iframe
try {
@@ -102,6 +133,11 @@ export class ContextBroker {
window.removeEventListener('message', this.listener)
this.listener = null
}
if (this.confirmPollTimer) {
clearInterval(this.confirmPollTimer)
this.confirmPollTimer = null
}
this.activeChatTurns = 0
}
sendPermissionsUpdate() {
@@ -145,6 +181,12 @@ export class ContextBroker {
case 'content:request':
this.handleContentRequest(msg.id, msg.kind, msg.scope)
break
// Deliberately NO arm for anything confirmation-shaped (13-08,
// D-11): a frame message whose `type` resembles a tool confirmation
// falls through here and is ignored. The confirmation dialog is
// opened only from assistant.pending's RPC response and resolved
// only via the host's own aiui:tool-confirm-response CustomEvent —
// asserted by iframe_message_cannot_open_or_resolve_confirmation.
}
}
@@ -155,6 +197,10 @@ export class ContextBroker {
// divergent security model D-02 exists to prevent. Do not "helpfully"
// add a permission check back into this handler.
private async handleChatRequest(id: string, text: string) {
// 13-08: while this turn is in flight the node may suspend on its
// confirm gate waiting for a human — poll assistant.pending so the
// trusted chrome can draw the dialog (see handleToolConfirmRequest).
this.beginConfirmPolling()
try {
const result = await rpcClient.call<{ text: string }>({
method: 'assistant.chat',
@@ -173,9 +219,99 @@ export class ContextBroker {
success: false,
error: err instanceof Error ? err.message : 'Chat request failed',
} satisfies ArchyChatResponse)
} finally {
this.endConfirmPolling()
}
}
private beginConfirmPolling() {
this.activeChatTurns += 1
if (this.confirmPollTimer) return
this.confirmPollTimer = setInterval(() => {
void this.checkPendingConfirmation()
}, ContextBroker.CONFIRM_POLL_MS)
}
private endConfirmPolling() {
this.activeChatTurns = Math.max(0, this.activeChatTurns - 1)
if (this.activeChatTurns === 0 && this.confirmPollTimer) {
clearInterval(this.confirmPollTimer)
this.confirmPollTimer = null
}
}
private async checkPendingConfirmation() {
try {
const res = await rpcClient.call<{ pending: PendingToolConfirm | null }>({
method: 'assistant.pending',
})
if (res?.pending) this.handleToolConfirmRequest(res.pending)
} catch {
// Transient RPC failure — the next poll retries; the node's own
// timeout is the backstop, and it declines rather than approves.
}
}
/**
* 13-08 (D-07/D-11): announce one node-reported pending confirmation to
* the trusted chrome, and arm a one-shot listener for the host's answer.
*
* Anti-spoofing invariants, all load-bearing:
* - The description and nonce arrive here ONLY from assistant.pending's
* RPC response — never from the iframe (there is no handleMessage arm
* for anything confirmation-shaped, deliberately).
* - The CustomEvent pair (`aiui:tool-confirm-request` /
* `aiui:tool-confirm-response`) is NEW and distinct from the
* install-app pair — an install confirmation and a tool confirmation
* must never be interchangeable.
* - The response listener listens for the host page's own CustomEvent on
* window. An iframe cannot dispatch that (its postMessage arrives as a
* MessageEvent, which this method never reads), so the decision path
* is host-only.
* - The user's decision travels to the node over the authenticated RPC
* session (assistant.confirm-tool) carrying the node-minted nonce —
* never back through the frame.
* - No response is ever synthesized: if the host closes the dialog
* without deciding, nothing is sent, and the node's own timeout
* declines the action.
*/
handleToolConfirmRequest(pending: PendingToolConfirm) {
if (!pending?.req_id || !pending.nonce || typeof pending.description !== 'string') return
if (this.announcedConfirmReqIds.has(pending.req_id)) return
this.announcedConfirmReqIds.add(pending.req_id)
const reqId = pending.req_id
const nonce = pending.nonce
const responseHandler = (e: Event) => {
const detail = (e as CustomEvent).detail as { reqId?: string; approved?: boolean }
if (detail?.reqId !== reqId) return
window.removeEventListener('aiui:tool-confirm-response', responseHandler)
void rpcClient
.call({
method: 'assistant.confirm-tool',
params: { req_id: reqId, nonce, approved: detail.approved === true },
})
.catch(() => {
// A refused resolution (stale nonce, already timed out) is the
// node protecting itself — nothing to retry from here.
})
}
window.addEventListener('aiui:tool-confirm-response', responseHandler)
setTimeout(() => {
window.removeEventListener('aiui:tool-confirm-response', responseHandler)
this.announcedConfirmReqIds.delete(reqId)
}, ContextBroker.CONFIRM_LISTENER_TTL_MS)
// Only node-fetched values travel to the chrome — and not the nonce:
// it stays in this closure and reappears only on the RPC call above.
window.dispatchEvent(
new CustomEvent('aiui:tool-confirm-request', {
detail: { reqId, description: pending.description, toolName: pending.tool_name },
}),
)
}
// Content surfaces (D-12/D-14, AIUI-03) — a single generic channel with a
// `kind` discriminator rather than one channel per content type, so
// 13-11's music-library wave can extend `kind` without touching this
+51
View File
@@ -70,6 +70,21 @@
</div>
</div>
<!-- 13-08 (D-11): the destructive-tool confirmation dialog trusted
chrome, mounted as a SIBLING of the iframe, never inside it. The
component Teleports to body with a full-screen backdrop, so it
covers the whole viewport including the area over the iframe, and
no ancestor transform can trap its position: fixed. Its text is
node-authored, fetched by the ContextBroker over the page's own
RPC session — nothing the iframe sends can open or resolve it. -->
<ToolConfirmModal
:show="!!toolConfirm"
:description="toolConfirm?.description ?? ''"
@approve="resolveToolConfirm(true)"
@deny="resolveToolConfirm(false)"
@dismiss="dismissToolConfirm"
/>
</div>
</template>
@@ -78,6 +93,7 @@ import { ref, computed, onActivated, onBeforeUnmount, onDeactivated, onMounted,
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ContextBroker } from '@/services/contextBroker'
import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
import { IS_DEMO } from '@/composables/useDemoIntro'
const { t } = useI18n()
@@ -170,6 +186,37 @@ function closeChat() {
}
}
// 13-08 (D-11): the pending destructive-tool confirmation the trusted
// chrome is currently showing. Set ONLY from the ContextBroker's
// aiui:tool-confirm-request CustomEvent, whose payload is node-fetched
// over the page's own RPC session — never from anything the iframe posts.
const toolConfirm = ref<{ reqId: string; description: string } | null>(null)
function onToolConfirmRequest(e: Event) {
const detail = (e as CustomEvent).detail as { reqId?: string; description?: string }
if (!detail?.reqId || typeof detail.description !== 'string') return
toolConfirm.value = { reqId: detail.reqId, description: detail.description }
}
function resolveToolConfirm(approved: boolean) {
const current = toolConfirm.value
toolConfirm.value = null
if (!current) return
// The decision travels back to the broker (and from there to the node
// over the authenticated RPC session) — never through the iframe.
window.dispatchEvent(
new CustomEvent('aiui:tool-confirm-response', {
detail: { reqId: current.reqId, approved },
}),
)
}
function dismissToolConfirm() {
// Closed without a decision: send nothing. The action stays pending on
// the node until its own timeout declines it — never silently approved.
toolConfirm.value = null
}
function onAiuiMessage(event: MessageEvent) {
if (!aiuiUrl.value) return
// Validate origin — only accept messages from AIUI
@@ -197,6 +244,8 @@ function onAiuiMessage(event: MessageEvent) {
function armChatLive() {
window.removeEventListener('message', onAiuiMessage)
window.addEventListener('message', onAiuiMessage)
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
window.addEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
broker?.stop()
broker = null
if (aiuiUrl.value) {
@@ -216,6 +265,7 @@ onActivated(() => armChatLive())
onDeactivated(() => {
window.removeEventListener('message', onAiuiMessage)
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
broker?.stop()
broker = null
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
@@ -229,6 +279,7 @@ onMounted(() => armChatLive())
onBeforeUnmount(() => {
window.removeEventListener('message', onAiuiMessage)
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
broker?.stop()
broker = null
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }