From 2f51041822cdb7e2ef1902979b386e48ffbd3958 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 29 Jul 2026 06:45:22 +0800 Subject: [PATCH 1/2] fix: fail closed when WeChat page.generate has no HTML artifact Prevent text-only planning replies from marking WeChat delivery done when static-page-publish never confirmed a public HTML file. Co-authored-by: Cursor --- wechat-mp.test.mjs | 11 ++++++----- wechat/handlers/page-generate.mjs | 8 +++++++- wechat/wechat-channel.test.mjs | 26 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 640aa91..57c8c8a 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -3288,8 +3288,8 @@ test('wechat mp service forwards H5 agent text when page generation produced no assert.equal(fs.existsSync(htmlPath), false); const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); const payload = JSON.parse(sendCall[2]); - assert.match(payload.text.content, /🌴 泰国简易攻略/); - assert.doesNotMatch(payload.text.content, /没有按 H5 里的页面技能真正生成成功/); + assert.doesNotMatch(payload.text.content, /🌴 泰国简易攻略/); + assert.match(payload.text.content, /没有按服务号页面技能真正生成成功/); assert.doesNotMatch(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/); }); @@ -3631,7 +3631,8 @@ test('wechat mp service recreates dedicated session when tool_calls error arrive assert.equal(started, true); const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); const payload = JSON.parse(sendCall[2]); - assert.match(payload.text.content, /已恢复,可以继续对话/); + assert.doesNotMatch(payload.text.content, /已恢复,可以继续对话/); + assert.match(payload.text.content, /没有按服务号页面技能真正生成成功/); assert.doesNotMatch(payload.text.content, /Bad request/); assert.doesNotMatch(payload.text.content, /tool_calls/); }); @@ -3965,8 +3966,8 @@ test('wechat mp service retries poisoned publish claims before forwarding H5 ret const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); const payload = JSON.parse(sendCall[2]); assert.doesNotMatch(payload.text.content, /页面都已经成功发布了|主题页面已发布/); - assert.match(payload.text.content, /🌴 夏日主题页面/); - assert.doesNotMatch(payload.text.content, /没有按 H5 里的页面技能真正生成成功/); + assert.doesNotMatch(payload.text.content, /🌴 夏日主题页面/); + assert.match(payload.text.content, /没有按服务号页面技能真正生成成功/); assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/); }); diff --git a/wechat/handlers/page-generate.mjs b/wechat/handlers/page-generate.mjs index 29ce593..d07546f 100644 --- a/wechat/handlers/page-generate.mjs +++ b/wechat/handlers/page-generate.mjs @@ -69,5 +69,11 @@ export function resolvePageGenerateOutcome({ }; } - return { action: 'send', artifacts: sendable }; + // Fail closed: page.generate must deliver a verified public HTML artifact. + // Text-only planning replies ("我先搜索…") must not mark WeChat delivery done. + return { + action: 'fail', + failureText: buildPagePublishFailureText(), + reason: 'missing_page_artifact', + }; } diff --git a/wechat/wechat-channel.test.mjs b/wechat/wechat-channel.test.mjs index 39e090e..a24e596 100644 --- a/wechat/wechat-channel.test.mjs +++ b/wechat/wechat-channel.test.mjs @@ -218,3 +218,29 @@ test('resolvePageGenerateOutcome sends when share preview meta is present', () = assert.equal(outcome.action, 'send'); assert.equal(outcome.artifacts.length, 1); }); + +test('resolvePageGenerateOutcome fails closed on planning text without html artifact', () => { + const outcome = resolvePageGenerateOutcome({ + reply: { + text: '找到了之前的新闻页面。让我先读取最新的页面格式作为参考,同时并行搜索今日新闻和天气。', + }, + confirmedArtifacts: [], + verifiedArtifacts: [], + }); + assert.equal(outcome.action, 'fail'); + assert.equal(outcome.reason, 'missing_page_artifact'); + assert.match(outcome.failureText, /没有按服务号页面技能真正生成成功|static-page-publish/); +}); + +test('resolvePageGenerateOutcome fails closed when reply links an old page but no artifact confirmed', () => { + const outcome = resolvePageGenerateOutcome({ + reply: { + text: '[每日新闻](https://m.tkmind.cn/MindSpace/u/public/daily-news-0728.html)', + }, + confirmedArtifacts: [], + verifiedArtifacts: [], + replyHasPublicLinks: true, + }); + assert.equal(outcome.action, 'fail'); + assert.equal(outcome.reason, 'missing_page_artifact'); +}); From 53c5f6d9c294f7a3b7e272856719a910dfed76b5 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 29 Jul 2026 14:04:15 +0800 Subject: [PATCH 2/2] fix(wechat): scrub image_url from sessions and harden vision handoff. Strip image_url from persisted session payloads, keep image turns scoped to the active request, and align proxy/vision/page-data paths so WeChat image history does not leak into fresh sessions. Co-authored-by: Cursor --- chat-image-turn-scope.mjs | 19 ++++++++++ chat-image-turn-scope.test.mjs | 44 +++++++++++++++++++++++ mindspace-scan.mjs | 1 + mindspace-scan.test.mjs | 15 ++++++++ page-data-browser-client.mjs | 3 ++ public/assets/page-data-client.js | 3 ++ tkmind-proxy-vision.test.mjs | 32 +++++++++++++++++ tkmind-proxy.mjs | 60 +++++++++++++++++++++++++++---- tkmind-proxy.test.mjs | 52 +++++++++++++++++++++++++++ 9 files changed, 222 insertions(+), 7 deletions(-) diff --git a/chat-image-turn-scope.mjs b/chat-image-turn-scope.mjs index 1d5aff7..fd207cb 100644 --- a/chat-image-turn-scope.mjs +++ b/chat-image-turn-scope.mjs @@ -130,6 +130,25 @@ export function scrubUserMessageImageAttachments(message) { }; } +export function messageContentHasImageUrl(content) { + if (!Array.isArray(content)) return false; + return content.some((item) => item?.type === 'image_url' && item?.image_url?.url); +} + +/** + * Any persisted image_url part will break DeepSeek / other text-only providers. + * User metadata.imageUrls alone is not enough — Goose may have expanded them into + * content parts on assistant or user turns. + */ +export function conversationHasImageUrlContent(conversation, { excludeMessageId = null } = {}) { + if (!Array.isArray(conversation)) return false; + const excluded = String(excludeMessageId ?? '').trim(); + return conversation.some((message) => { + if (excluded && String(message?.id ?? '').trim() === excluded) return false; + return messageContentHasImageUrl(message?.content); + }); +} + export function scrubConversationHistoricalImageAttachments(conversation, activeMessageId) { const activeId = String(activeMessageId ?? '').trim(); if (!Array.isArray(conversation) || !activeId) { diff --git a/chat-image-turn-scope.test.mjs b/chat-image-turn-scope.test.mjs index 9d50704..8211812 100644 --- a/chat-image-turn-scope.test.mjs +++ b/chat-image-turn-scope.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildCurrentTurnImageScopeNote, + conversationHasImageUrlContent, dedupeImageUrlsByAssetKey, extractCurrentTurnImageUrls, scrubConversationHistoricalImageAttachments, @@ -113,3 +114,46 @@ test('buildCurrentTurnImageScopeNote states one independent topic per upload', ( assert.match(note, /不得与历史轮次混用/); assert.match(note, /asset=asset-9/); }); + +test('conversationHasImageUrlContent detects historical poison and ignores active turn', () => { + const conversation = [ + { + id: 'assistant-old', + role: 'assistant', + content: [ + { type: 'text', text: '看图' }, + { type: 'image_url', image_url: { url: 'https://example.com/old.png' } }, + ], + }, + { + id: 'user-new', + role: 'user', + content: [ + { type: 'text', text: '这是什么' }, + { type: 'image_url', image_url: { url: 'https://example.com/new.png' } }, + ], + }, + ]; + + assert.equal(conversationHasImageUrlContent(conversation), true); + assert.equal( + conversationHasImageUrlContent(conversation, { excludeMessageId: 'user-new' }), + true, + ); + assert.equal( + conversationHasImageUrlContent( + [ + { + id: 'user-new', + role: 'user', + content: [ + { type: 'text', text: '这是什么' }, + { type: 'image_url', image_url: { url: 'https://example.com/new.png' } }, + ], + }, + ], + { excludeMessageId: 'user-new' }, + ), + false, + ); +}); diff --git a/mindspace-scan.mjs b/mindspace-scan.mjs index ec069c1..2b46ae4 100644 --- a/mindspace-scan.mjs +++ b/mindspace-scan.mjs @@ -7,6 +7,7 @@ const BLOCKED_ACTIVE_PATTERNS = [ const SCRIPT_TAG_PATTERN = /]*)>([\s\S]*?)<\/script>/gi; const SCRIPT_SRC_PATTERN = /\bsrc\s*=\s*(['"])([^'"]+)\1/i; const TRUSTED_SCRIPT_SRC_PATTERNS = [ + /^\/assets\/page-data-client\.js(?:[?#].*)?$/i, /^\/assets\/chart\.umd\.min\.js(?:[?#].*)?$/i, /^https:\/\/cdn\.jsdelivr\.net\/npm\/chart\.js(?:@[^/]+)?\/dist\/chart\.(?:umd\.)?min\.js(?:[?#].*)?$/i, /^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/Chart\.js\/[^/]+\/chart\.(?:umd\.)?min\.js(?:[?#].*)?$/i, diff --git a/mindspace-scan.test.mjs b/mindspace-scan.test.mjs index a7f4d96..1bccbbf 100644 --- a/mindspace-scan.test.mjs +++ b/mindspace-scan.test.mjs @@ -35,6 +35,21 @@ test('runBasicFileScan warns for sandboxed html with inline script and trusted C assert.deepEqual(result.findings, ['trusted_html_active_content']); }); +test('runBasicFileScan warns for sandboxed html with page-data-client and inline script', () => { + const result = runBasicFileScan( + Buffer.from(` + + `), + { + filename: 'survey.html', + mimeType: 'text/html', + htmlActiveContentPolicy: 'sandbox_warn', + }, + ); + assert.equal(result.scanStatus, 'warned'); + assert.deepEqual(result.findings, ['trusted_html_active_content']); +}); + test('runBasicFileScan still blocks unsafe html active content in sandbox mode', () => { const javascriptUrl = runBasicFileScan(Buffer.from('go'), { filename: 'dashboard.html', diff --git a/page-data-browser-client.mjs b/page-data-browser-client.mjs index 7e7118d..ea19644 100644 --- a/page-data-browser-client.mjs +++ b/page-data-browser-client.mjs @@ -88,6 +88,9 @@ export function createPageDataBrowserClient({ async listRows(dataset, query = {}) { return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset), { query }); }, + async readRows(dataset, query = {}) { + return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset), { query }); + }, async getSchema(dataset) { return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset, 'schema')); }, diff --git a/public/assets/page-data-client.js b/public/assets/page-data-client.js index d3ef5c6..2bc8fd4 100644 --- a/public/assets/page-data-client.js +++ b/public/assets/page-data-client.js @@ -106,6 +106,9 @@ listRows: function (dataset, query) { return request('GET', buildDataPath(apiBase, pageId, dataset), { query: query || {} }); }, + readRows: function (dataset, query) { + return request('GET', buildDataPath(apiBase, pageId, dataset), { query: query || {} }); + }, getSchema: function (dataset) { return request('GET', buildDataPath(apiBase, pageId, dataset, 'schema')); }, diff --git a/tkmind-proxy-vision.test.mjs b/tkmind-proxy-vision.test.mjs index 068d665..c4fca8e 100644 --- a/tkmind-proxy-vision.test.mjs +++ b/tkmind-proxy-vision.test.mjs @@ -140,3 +140,35 @@ test('buildVisionPayload does not mark billable usage when vision analysis fails assert.equal(result?.billableImageCount, 0); assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /Qwen VL 图片描述/); }); + +test('buildVisionPayload strips image_url content parts for text-only Goose providers', async () => { + const result = await buildVisionPayload({ + userId: 'user-1', + publishLayout: { publicUrl: 'https://m.tkmind.cn/MindSpace/user-1' }, + userMessage: { + content: [ + { type: 'text', text: '这是什么' }, + { + type: 'image_url', + image_url: { url: '/api/mindspace/v1/assets/asset-7/download?inline=1' }, + }, + ], + metadata: { + imageUrls: ['/api/mindspace/v1/assets/asset-7/download?inline=1'], + }, + }, + localFetchAsset: async () => ({ + buffer: Buffer.from('fake-image'), + mimeType: 'image/png', + }), + llmProviderService: { + analyzeImagesWithVision: async () => '蓝色方块', + }, + }); + + assert.equal( + (result?.userMessage?.content ?? []).some((item) => item?.type === 'image_url'), + false, + ); + assert.match(result?.userMessage?.content?.[0]?.text ?? '', /蓝色方块/); +}); diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index c8aff0c..43004c8 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -34,6 +34,7 @@ import { import { extractAttachmentText } from './mindspace-attachment-text.mjs'; import { buildCurrentTurnImageScopeNote, + conversationHasImageUrlContent, extractCurrentTurnImageUrls, scrubConversationHistoricalImageAttachments, } from './chat-image-turn-scope.mjs'; @@ -974,6 +975,9 @@ export async function buildVisionPayload({ '不要向用户展示 HTML 代码块。'; let updatedContent = Array.isArray(userMessage?.content) ? [...userMessage.content] : []; + // Text-only Goose providers cannot accept image_url parts. After VL analysis, + // keep only text (with the injected vision note) for the agent turn. + updatedContent = updatedContent.filter((item) => item?.type !== 'image_url'); for (const item of imageItems) { updatedContent = updatedContent.map((c) => { if (c?.type !== 'text' || typeof c.text !== 'string') return c; @@ -1698,27 +1702,59 @@ export function createTkmindProxy({ return { changed: false, updated: false, status: upstream.status }; } const session = await upstream.json().catch(() => null); - const { conversation, changed } = scrubConversationHistoricalImageAttachments( - session?.conversation ?? [], + const conversation = Array.isArray(session?.conversation) ? session.conversation : []; + const hasImageUrlContent = conversationHasImageUrlContent(conversation, { + excludeMessageId: activeId, + }); + const { conversation: scrubbedConversation, changed } = scrubConversationHistoricalImageAttachments( + conversation, activeId, ); - if (!changed) return { changed: false, updated: false }; + // Text-only providers (DeepSeek) reject any lingering image_url parts. If Goose + // cannot persist a scrub (405/404), callers must rotate to a fresh session. + if (!changed && !hasImageUrlContent) { + return { changed: false, updated: false, hasImageUrlContent: false }; + } + if (!changed && hasImageUrlContent) { + return { + changed: true, + updated: false, + status: 'image_url_content_present', + hasImageUrlContent: true, + }; + } const update = await apiFetch( target, apiSecret, `/sessions/${encodeURIComponent(sessionId)}`, { method: 'PUT', - body: JSON.stringify({ conversation }), + body: JSON.stringify({ conversation: scrubbedConversation }), }, ); if (!update.ok) { console.warn( `Historical image scrub skipped for session ${sessionId}: upstream ${update.status}`, ); - return { changed: true, updated: false, status: update.status }; + return { + changed: true, + updated: false, + status: update.status, + hasImageUrlContent: + hasImageUrlContent + || conversationHasImageUrlContent(scrubbedConversation, { + excludeMessageId: activeId, + }), + }; } - return { changed: true, updated: true, status: update.status }; + return { + changed: true, + updated: true, + status: update.status, + hasImageUrlContent: conversationHasImageUrlContent(scrubbedConversation, { + excludeMessageId: activeId, + }), + }; } catch (err) { console.warn( 'Historical image scrub skipped:', @@ -1938,7 +1974,17 @@ export function createTkmindProxy({ if (!user) throw new Error('用户不存在'); if (requireHistoricalImageIsolation || messageHasImages(userMessage)) { const imageIsolation = await syncHistoricalImageTurnIsolation(sessionId, userMessage?.id); - if (requireHistoricalImageIsolation && imageIsolation.changed && !imageIsolation.updated) { + const scrubUnsupported = + imageIsolation.changed + && !imageIsolation.updated + && ( + requireHistoricalImageIsolation + || imageIsolation.hasImageUrlContent + || Number(imageIsolation.status) === 404 + || Number(imageIsolation.status) === 405 + || imageIsolation.status === 'image_url_content_present' + ); + if (scrubUnsupported) { const error = new Error( `historical_image_session_update_unsupported:${imageIsolation.status ?? 'unknown'}`, ); diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs index 1458e64..04b12fa 100644 --- a/tkmind-proxy.test.mjs +++ b/tkmind-proxy.test.mjs @@ -1534,6 +1534,58 @@ test('submitSessionReplyForUser fails closed when historical image scrub is unsu }); }); +test('submitSessionReplyForUser rotates when assistant history still has image_url', async () => { + await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => { + const proxy = createTkmindProxy({ + apiTarget, + apiSecret: 'test-secret', + userAuth: { + ...createMemoryTestUserAuth(workingDir), + async ownsSession() { + return true; + }, + async canUseChat() { + return { ok: true }; + }, + async getUserById() { + return { id: 'user-1' }; + }, + async resolveUserPolicies() { + return { unrestricted: true, policies: {} }; + }, + }, + }); + + await assert.rejects( + proxy.submitSessionReplyForUser( + 'user-1', + 'session-1', + 'request-after-assistant-image', + { + id: 'message-current', + role: 'user', + content: [{ type: 'text', text: '这张图是什么' }], + metadata: { imageUrls: ['https://example.com/new.png'] }, + }, + { requireHistoricalImageIsolation: true }, + ), + /historical_image_session_update_unsupported:image_url_content_present/, + ); + assert.equal(replyBodies.length, 0); + }, { + conversation: [ + { + id: 'assistant-old-image', + role: 'assistant', + content: [ + { type: 'text', text: '我看到了图片' }, + { type: 'image_url', image_url: { url: 'https://example.com/old.png' } }, + ], + }, + ], + }); +}); + test('visual fallback session removes read_image while preserving the text task path', async () => { await withFakeGoosedSession(async ({ apiTarget,