fix(agent): scope secret confirmation controls (#671)

This commit is contained in:
InfinityPacer
2026-08-13 07:39:47 +08:00
committed by GitHub
parent 2b0325e02b
commit 2c4b859729
2 changed files with 81 additions and 155 deletions

View File

@@ -181,13 +181,6 @@ interface AgentStreamReadResult {
receivedTerminalEvent: boolean
}
interface AgentProtectedDelivery {
/** 一次性投递标识,只用于当前可见范围去重。 */
id: string
/** 仅驻留组件内存并按纯文本渲染的受保护内容。 */
content: string
}
interface ParsedSseBlock {
eventName: string
data: string
@@ -249,7 +242,8 @@ const STREAM_STATE_PERSIST_DELAY = 1000
const inputText = ref('')
const messages = ref<AgentChatMessage[]>([])
const protectedDeliveries = ref<AgentProtectedDelivery[]>([])
// 受保护内容只驻留当前组件内存,不进入消息历史或本地持久化。
const protectedDeliveries = ref<string[]>([])
const historySessions = ref<AgentSessionHistoryItem[]>([])
const sessionId = ref('')
const sending = ref(false)
@@ -290,7 +284,6 @@ let streamRecoveryAbortRequested = false
let streamRecoveryTimer: number | null = null
let activeStreamStartedAt = 0
let protectedDeliveryGeneration = 0
const protectedDeliveryIds = new Set<string>()
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
@@ -365,12 +358,6 @@ function createSessionId() {
function invalidateProtectedDeliveries() {
protectedDeliveryGeneration += 1
protectedDeliveries.value = []
protectedDeliveryIds.clear()
}
// 识别前端需要隔离展示和持久化的确认控制文本,授权判断仍由后端完成。
function isReservedConfirmationControl(value: string) {
return /^(确认|取消) ([A-Za-z0-9]{4}-[A-Za-z0-9]{4})$/.test(value)
}
// 将未知字段安全转换为可展示文本。
@@ -1574,25 +1561,10 @@ function splitSseBlock(block: string) {
return { eventName, data } satisfies ParsedSseBlock
}
function parseProtectedTransportFrame(data: string): AgentProtectedDelivery | null {
function parseProtectedTransportFrame(data: string): string | null {
try {
const frame = JSON.parse(data) as Record<string, unknown>
const schemaVersion = typeof frame.schema_version === 'string' ? frame.schema_version : ''
const versionMatch = /^(\d+)\.(\d+)$/.exec(schemaVersion)
const deliveryId = frame.delivery_id
if (
!versionMatch ||
versionMatch[1] !== '1' ||
typeof deliveryId !== 'string' ||
!deliveryId.trim() ||
frame.content_type !== 'text/plain' ||
typeof frame.content !== 'string'
) {
return null
}
return { id: deliveryId, content: frame.content }
return typeof frame.content === 'string' ? frame.content : null
} catch {
return null
}
@@ -1601,11 +1573,10 @@ function parseProtectedTransportFrame(data: string): AgentProtectedDelivery | nu
function consumeProtectedTransportFrame(data: string, streamGeneration: number) {
if (!isOpen.value || streamGeneration !== protectedDeliveryGeneration) return
const delivery = parseProtectedTransportFrame(data)
if (!delivery || protectedDeliveryIds.has(delivery.id)) return
const content = parseProtectedTransportFrame(data)
if (content === null) return
protectedDeliveryIds.add(delivery.id)
protectedDeliveries.value.push(delivery)
protectedDeliveries.value.push(content)
nextTick(() => scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow }))
}
@@ -1859,7 +1830,9 @@ async function streamAgentMessage(
const displayContent = (displayText ?? content).trim()
if (!content && !images.length && !files.length && !audioRefs.length) return
if (echoUser) addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection)
const userMessage = echoUser
? addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection)
: null
const assistantMessage = addMessage('assistant', '', 'streaming')
abortController = new AbortController()
@@ -1897,6 +1870,11 @@ async function streamAgentMessage(
if (!response.ok) {
throw new Error(await resolveAgentResponseErrorMessage(response))
}
if (response.headers.get('X-MoviePilot-Agent-Control') === 'secret-confirmation' && userMessage) {
messages.value = messages.value.filter(message => message.id !== userMessage.id)
refreshMessageList()
persistState()
}
const streamResult = await readAgentStream(response, assistantMessage, streamProtectedDeliveryGeneration)
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
@@ -1979,10 +1957,7 @@ async function sendMessage() {
try {
const prepared = await prepareAgentAttachments(attachments)
const reservedControl = attachments.length === 0 && isReservedConfirmationControl(rawText)
await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments, {
echoUser: !reservedControl,
})
await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments)
} catch (error: any) {
// 附件准备失败同样落到对话消息里,底部提示条只保留给没有消息承载的本地错误。
addMessage('assistant', error?.message || t('agentAssistant.uploadFailed'), 'error')
@@ -2725,13 +2700,12 @@ onScopeDispose(() => {
<div v-if="protectedDeliveries.length" class="agent-assistant-protected-deliveries">
<div
v-for="delivery in protectedDeliveries"
:key="delivery.id"
v-for="(content, index) in protectedDeliveries"
:key="index"
class="agent-assistant-protected-delivery"
:data-protected-delivery-id="delivery.id"
>
<VIcon icon="mdi-shield-lock-outline" size="16" aria-hidden="true" />
<span v-text="delivery.content" />
<span v-text="content" />
</div>
</div>
</div>

View File

@@ -107,14 +107,14 @@ function protectedSseFrame(data: Record<string, unknown>): SyntheticSseFrame {
return { eventName: 'interaction-protected', data }
}
function createAgentStreamResponse(frames: SyntheticSseFrame[]) {
function createAgentStreamResponse(frames: SyntheticSseFrame[], headers: Record<string, string> = {}) {
const body = frames
.map(frame => `${frame.eventName ? `event: ${frame.eventName}\n` : ''}data: ${JSON.stringify(frame.data)}\n\n`)
.join('')
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
headers: { 'Content-Type': 'text/event-stream', ...headers },
})
}
@@ -502,12 +502,7 @@ describe('AgentAssistantPanel stream recovery', () => {
return createAgentStreamResponse([
legacySseFrame({ type: 'start', session_id: 'web-agent:protected' }),
legacySseFrame({ type: 'delta', content: '普通回复' }),
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-1',
content_type: 'text/plain',
content: protectedMarker,
}),
protectedSseFrame({ content: protectedMarker }),
legacySseFrame({ type: 'tool', message: '(查询了 1 次数据)' }),
legacySseFrame({ type: 'done' }),
])
@@ -522,7 +517,7 @@ describe('AgentAssistantPanel stream recovery', () => {
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const protectedNode = wrapper.get('[data-protected-delivery-id="delivery-1"]')
const protectedNode = wrapper.get('.agent-assistant-protected-delivery')
expect(protectedNode.text()).toBe(protectedMarker)
expect(protectedNode.find('script').exists()).toBe(false)
const assistantBubble = wrapper.get('.agent-assistant-message--assistant .agent-assistant-message__bubble')
@@ -698,7 +693,7 @@ describe('AgentAssistantPanel stream recovery', () => {
wrapper.unmount()
})
it('ignores malformed and duplicate protected delivery events without creating a normal message', async () => {
it('accepts only string content from the named protected event', async () => {
const acceptedMarker = 'MP-ACCEPTED-PROTECTED-MARKER'
const rejectedMarker = 'MP-REJECTED-PROTECTED-MARKER'
vi.stubGlobal(
@@ -707,45 +702,10 @@ describe('AgentAssistantPanel stream recovery', () => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
legacySseFrame({ type: 'start', session_id: 'web-agent:protected-validation' }),
protectedSseFrame({
schema_version: '2.0',
delivery_id: 'wrong-major',
content_type: 'text/plain',
content: rejectedMarker,
}),
protectedSseFrame({
schema_version: '1.0',
delivery_id: '',
content_type: 'text/plain',
content: rejectedMarker,
}),
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'wrong-content-type',
content_type: 'text/html',
content: rejectedMarker,
}),
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'bad-content',
content_type: 'text/plain',
content: 42,
}),
protectedSseFrame({ content: 42 }),
{ eventName: 'interaction', data: { content: rejectedMarker } },
legacySseFrame({ type: 'protected_delivery', delivery_id: 'legacy-shape', content: rejectedMarker }),
protectedSseFrame({
schema_version: '1.7',
delivery_id: 'delivery-valid',
content_type: 'text/plain',
content: acceptedMarker,
future_field: true,
}),
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-valid',
content_type: 'text/plain',
content: rejectedMarker,
}),
protectedSseFrame({ content: acceptedMarker }),
legacySseFrame({ type: 'done' }),
])
}
@@ -786,12 +746,7 @@ describe('AgentAssistantPanel stream recovery', () => {
stream.emit(legacySseFrame({ type: 'start', session_id: 'web-agent:late-protected' }))
stream.emit(
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-before-close',
content_type: 'text/plain',
content: protectedMarker,
}),
protectedSseFrame({ content: protectedMarker }),
)
await flushPromises()
expect(wrapper.text()).toContain(protectedMarker)
@@ -800,12 +755,7 @@ describe('AgentAssistantPanel stream recovery', () => {
expect(wrapper.text()).not.toContain(protectedMarker)
stream.emit(
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-after-close',
content_type: 'text/plain',
content: protectedMarker,
}),
protectedSseFrame({ content: protectedMarker }),
)
await wrapper.setProps({ modelValue: true })
await flushPromises()
@@ -832,12 +782,7 @@ describe('AgentAssistantPanel stream recovery', () => {
await flushPromises()
stream.emit(
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-before-eof',
content_type: 'text/plain',
content: protectedMarker,
}),
protectedSseFrame({ content: protectedMarker }),
)
await flushPromises()
expect(wrapper.text()).toContain(protectedMarker)
@@ -873,12 +818,7 @@ describe('AgentAssistantPanel stream recovery', () => {
await flushPromises()
stream.emit(
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-before-network-error',
content_type: 'text/plain',
content: protectedMarker,
}),
protectedSseFrame({ content: protectedMarker }),
)
await flushPromises()
expect(wrapper.text()).toContain(protectedMarker)
@@ -905,12 +845,7 @@ describe('AgentAssistantPanel stream recovery', () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-session',
content_type: 'text/plain',
content: protectedMarker,
}),
protectedSseFrame({ content: protectedMarker }),
legacySseFrame({ type: 'done' }),
])
}
@@ -964,12 +899,7 @@ describe('AgentAssistantPanel stream recovery', () => {
const url = String(input)
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
protectedSseFrame({
schema_version: '1.0',
delivery_id: 'delivery-history',
content_type: 'text/plain',
content: protectedMarker,
}),
protectedSseFrame({ content: protectedMarker }),
legacySseFrame({ type: 'done' }),
])
}
@@ -993,16 +923,19 @@ describe('AgentAssistantPanel stream recovery', () => {
wrapper.unmount()
})
it('keeps exact confirmation controls out of Web history while preserving the old-backend response', async () => {
const controlText = '确认 aB12-cD34'
it('keeps exact confirmation controls out of Web history while preserving the backend response', async () => {
const controlText = '确认'
const backendFeedback = '确认无效或已过期'
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
legacySseFrame({ type: 'start', session_id: 'web-agent:control' }),
legacySseFrame({ type: 'delta', content: backendFeedback }),
legacySseFrame({ type: 'done' }),
])
return createAgentStreamResponse(
[
legacySseFrame({ type: 'start', session_id: 'web-agent:control' }),
legacySseFrame({ type: 'delta', content: backendFeedback }),
legacySseFrame({ type: 'done' }),
],
{ 'X-MoviePilot-Agent-Control': 'secret-confirmation' },
)
}
return createAgentResponse([])
@@ -1016,28 +949,52 @@ describe('AgentAssistantPanel stream recovery', () => {
const streamCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/stream'))
const streamBody = JSON.parse(String(streamCall?.[1]?.body || '{}'))
expect(streamBody).toMatchObject({ text: controlText, display_text: controlText, echo_user: false })
expect(streamBody).toMatchObject({ text: controlText, display_text: controlText, echo_user: true })
expect(wrapper.text()).toContain(backendFeedback)
expect(wrapper.find('.agent-assistant-message--user').exists()).toBe(false)
expect(localStorage.getItem('moviepilot-agent-assistant-state')).not.toContain(controlText)
expect(localStorage.getItem('moviepilot-agent-assistant-history')).not.toContain(controlText)
const localState = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')
const localHistory = JSON.parse(localStorage.getItem('moviepilot-agent-assistant-history') || '[]')
expect(localState.messages || []).not.toContainEqual(expect.objectContaining({ role: 'user', content: controlText }))
expect(localHistory).not.toContainEqual(expect.objectContaining({ role: 'user', content: controlText }))
fetchMock.mock.calls
.filter(([input]) => String(input).includes('/display'))
.forEach(([, init]) => expect(String(init?.body)).not.toContain(controlText))
.forEach(([, init]) => {
const displayBody = JSON.parse(String(init?.body || '{}'))
expect(displayBody.messages || []).not.toContainEqual(
expect.objectContaining({ role: 'user', content: controlText }),
)
})
wrapper.unmount()
})
it('keeps an exact confirmation word on the ordinary path without a pending control', async () => {
const controlText = '确认'
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([legacySseFrame({ type: 'done' })])
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountPanel()
await wrapper.find('textarea').setValue(controlText)
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const streamCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/message/agent/stream'))
const streamBody = JSON.parse(String(streamCall?.[1]?.body || '{}'))
expect(streamBody.echo_user).toBe(true)
expect(wrapper.find('.agent-assistant-message--user').text()).toContain(controlText)
wrapper.unmount()
})
it.each([
['leading space', ' 确认 AB12-CD34'],
['trailing space', '确认 AB12-CD34 '],
['repeated separator', '确认 AB12-CD34'],
['trailing newline', '确认 AB12-CD34\n'],
['non-breaking space', '确认\u00a0AB12-CD34'],
['full-width space', '确认 AB12-CD34'],
['extra prose', '确认 AB12-CD34 请执行'],
['malformed code', '确认 AB12-CD3'],
['different verb', '同意 AB12-CD34'],
['confirmation with details', '确认 TMDB_API_KEY'],
['cancellation with details', '取消 TMDB_API_KEY'],
['extra prose', '确认,请执行'],
['different verb', '同意'],
])('keeps the %s control-like text on the ordinary echo path', async (_caseName, controlLikeText) => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
@@ -1060,7 +1017,7 @@ describe('AgentAssistantPanel stream recovery', () => {
})
it('keeps an exact confirmation string with an attachment on the ordinary echo path', async () => {
const controlText = '确认 AB12-CD34'
const controlText = '确认'
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/message/agent/upload') && init?.method === 'POST') {
@@ -1142,12 +1099,7 @@ describe('AgentAssistantPanel stream recovery', () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return createAgentStreamResponse([
protectedSseFrame({
schema_version: '1.0',
delivery_id: `delivery-${fetchMock.mock.calls.length}`,
content_type: 'text/plain',
content: protectedMarker,
}),
protectedSseFrame({ content: protectedMarker }),
legacySseFrame({ type: 'done' }),
])
}