Compare commits

...

4 Commits

Author SHA1 Message Date
john 7b3acb6813 fix(wechat): retry missing page thumbnails in fresh sessions
Memind CI / Test, build, and release guards (pull_request) Successful in 4m1s
2026-07-22 09:38:13 +08:00
tkmind aafda0cf64 merge: trust configured imgproxy generated images
Memind CI / Test, build, and release guards (push) Successful in 3m40s
Service-account generated images may be served from the configured imgproxy origin. Preserve explicit origin trust and reject unknown hosts.
2026-07-22 01:10:46 +00:00
john c6ae7cd21c fix(wechat): trust configured imgproxy generated images
Memind CI / Test, build, and release guards (pull_request) Successful in 2m56s
2026-07-22 09:05:18 +08:00
tkmind 1765cac65a merge: honor explicit WeChat page generation negation
Memind CI / Test, build, and release guards (push) Successful in 2m49s
Merge real-WeChat acceptance fix after successful CI.
2026-07-21 15:44:24 +00:00
5 changed files with 279 additions and 7 deletions
+12 -2
View File
@@ -131,6 +131,7 @@ export async function uploadWechatGeneratedImage(
{
wechatFetch = undiciFetch,
publicBaseUrl = '',
allowedPublicBaseUrls = [],
uploadUrl = DEFAULT_WECHAT_MEDIA_UPLOAD_URL,
maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES,
} = {},
@@ -138,8 +139,17 @@ export async function uploadWechatGeneratedImage(
if (!accessToken) throw new Error('缺少微信 access_token');
if (!publicUrl) throw new Error('缺少生成图片公网地址');
const resolvedUrl = new URL(String(publicUrl), publicBaseUrl || undefined).toString();
if (publicBaseUrl && new URL(resolvedUrl).origin !== new URL(publicBaseUrl).origin) {
throw new Error('生成图片地址不属于当前 MindSpace 公网域名');
const allowedOrigins = new Set();
for (const baseUrl of [publicBaseUrl, ...allowedPublicBaseUrls]) {
if (!baseUrl) continue;
try {
allowedOrigins.add(new URL(String(baseUrl)).origin);
} catch {
// Ignore invalid optional bases; at least one valid configured origin is required below.
}
}
if (allowedOrigins.size > 0 && !allowedOrigins.has(new URL(resolvedUrl).origin)) {
throw new Error('生成图片地址不属于当前 MindSpace 可信公网域名');
}
const sourceResponse = await wechatFetch(resolvedUrl, {
method: 'GET',
+35
View File
@@ -36,3 +36,38 @@ test('uploadWechatGeneratedImage converts a generated asset and uploads WeChat i
assert.match(calls[1].url, /access_token=access-1/);
assert.match(calls[1].url, /type=image/);
});
test('uploadWechatGeneratedImage accepts configured imgproxy origin and rejects unknown origins', async () => {
const source = await sharp({
create: { width: 16, height: 16, channels: 3, background: '#884422' },
}).png().toBuffer();
const wechatFetch = async (url) => {
if (String(url).startsWith('https://img.example.com/')) {
return new Response(source, { status: 200, headers: { 'Content-Type': 'image/png' } });
}
return new Response(JSON.stringify({ type: 'image', media_id: 'wx-media-imgproxy' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
const accepted = await uploadWechatGeneratedImage(
'access-2',
'https://img.example.com/signed/generated.webp',
{
publicBaseUrl: 'https://app.example.com',
allowedPublicBaseUrls: ['https://img.example.com'],
wechatFetch,
},
);
assert.equal(accepted.mediaId, 'wx-media-imgproxy');
await assert.rejects(
uploadWechatGeneratedImage('access-2', 'https://untrusted.example.net/generated.webp', {
publicBaseUrl: 'https://app.example.com',
allowedPublicBaseUrls: ['https://img.example.com'],
wechatFetch,
}),
/可信公网域名/,
);
});
+6
View File
@@ -36,6 +36,11 @@ export function loadWechatMpConfig(env = process.env) {
env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? '';
const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? '';
const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? '';
const generatedImagePublicBaseUrls = [...new Set([
publicBaseUrl,
env.IMGPROXY_BASE_URL?.trim()?.replace(/\/$/, '') ?? '',
...parseCsvList(env.H5_WECHAT_MP_GENERATED_IMAGE_BASE_URLS).map((value) => value.replace(/\/$/, '')),
].filter(Boolean))];
const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1';
const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
return {
@@ -73,6 +78,7 @@ export function loadWechatMpConfig(env = process.env) {
DEFAULT_WECHAT_JSAPI_TICKET_URL,
mediaPublicBaseUrl:
env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl,
generatedImagePublicBaseUrls,
maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)),
maxFileBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_FILE_BYTES ?? 30 * 1024 * 1024)),
acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0',
+11 -5
View File
@@ -1030,6 +1030,7 @@ export function isRecoverableWechatAgentSessionError(message) {
const normalized = String(message ?? '').trim();
if (!normalized) return false;
if (/stale_session_poisoned_completion/i.test(normalized)) return true;
if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true;
if (/403|404|not found|无权访问/i.test(normalized)) return true;
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
@@ -1801,6 +1802,7 @@ export function createWechatMpService({
const uploaded = await uploadWechatGeneratedImage(accessToken, publicUrl, {
wechatFetch,
publicBaseUrl: config.publicBaseUrl,
allowedPublicBaseUrls: config.generatedImagePublicBaseUrls,
});
const payload = await readJsonResponse(
await wechatFetch(
@@ -1839,6 +1841,7 @@ export function createWechatMpService({
user,
imagePolicy,
publishDir,
notifyFailure = true,
}) => {
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
const images = collectWechatGeneratedImages(reply?.messages ?? []);
@@ -1859,14 +1862,16 @@ export function createWechatMpService({
}
}
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
try {
await sendCustomerServiceText(openid, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
if (notifyFailure) {
try {
await sendCustomerServiceText(openid, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
}
}
const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`);
error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED';
throw markWechatUserNotified(error);
throw notifyFailure ? markWechatUserNotified(error) : error;
};
const ensureSessionProvider = async (sessionId) => {
@@ -2350,6 +2355,7 @@ export function createWechatMpService({
user,
imagePolicy,
publishDir: workingDir,
notifyFailure: false,
});
}
const pageDataOutcome = await enforcePageDataCollectDelivery({
+215
View File
@@ -859,10 +859,15 @@ test('loadWechatMpConfig requires full config and enable flag', () => {
H5_WECHAT_MP_APP_SECRET: 'secret',
H5_WECHAT_MP_TOKEN: 'token',
H5_PUBLIC_BASE_URL: 'https://example.com',
IMGPROXY_BASE_URL: 'https://img.example.com',
});
assert.equal(config.enabled, true);
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login');
assert.equal(config.requireFreshPageThumbnail, true);
assert.deepEqual(config.generatedImagePublicBaseUrls, [
'https://example.com',
'https://img.example.com',
]);
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false);
});
@@ -2718,6 +2723,12 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
true,
);
assert.equal(isRecoverableWechatAgentSessionError('stale_session_poisoned_completion'), true);
assert.equal(
isRecoverableWechatAgentSessionError(
'wechat_page_fresh_thumbnail_required:fresh_image_not_generated',
),
true,
);
assert.equal(isRecoverableWechatAgentSessionError('无权访问该会话'), true);
assert.equal(
isRecoverableWechatAgentSessionError('Session already has an active request. Cancel it first.'),
@@ -5114,6 +5125,210 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
assert.match(wechatPayloads[0].text.content, /fresh\.html/);
});
test('wechat mp retries a page in a new session before reporting a missing fresh thumbnail', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-retry-');
const htmlPath = path.join(workspaceRoot, 'public', 'retry.html');
const generated = {
ok: true,
jobId: 'job-fresh-page-retry',
source: { mimeType: 'image/webp', width: 1280, height: 720 },
asset: {
id: 'asset-fresh-page-retry',
htmlSrc: 'images/retry-cover.webp',
publicUrl: 'https://example.com/MindSpace/user-1/public/images/retry-cover.webp',
workspaceRelativePath: 'public/images/retry-cover.webp',
},
};
const firstHtml = previewReadyPageHtml({
title: 'Retry',
subtitle: '首次没有新缩略图',
cover: 'images/missing-cover.webp',
});
const retryHtml = previewReadyPageHtml({
title: 'Retry',
subtitle: '重试生成新缩略图',
cover: generated.asset.htmlSrc,
});
const pageImage = await sharp({
create: { width: 64, height: 64, channels: 3, background: '#cc6633' },
}).webp().toBuffer();
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
const wechatPayloads = [];
let routeCleared = false;
let startedSessions = 0;
const replyEvents = ({ requestId, html, includeImage = false }) => [
eventFrame({
type: 'Message',
request_id: requestId,
message: {
id: `${requestId}-tools`,
role: 'assistant',
metadata: { userVisible: true },
content: [
{
id: `${requestId}-page-skill`,
type: 'toolRequest',
toolCall: { value: { name: 'load_skill', arguments: { name: 'static-page-publish' } } },
},
...(includeImage
? [
{
id: `${requestId}-image`,
type: 'toolRequest',
toolCall: {
value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } },
},
},
{
id: `${requestId}-image`,
type: 'toolResponse',
toolResult: {
status: 'success',
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
},
},
]
: []),
{
id: `${requestId}-write`,
type: 'toolRequest',
toolCall: {
value: {
name: 'sandbox-fs__write_file',
arguments: { path: 'public/retry.html', content: html },
},
},
},
],
},
}),
eventFrame({
type: 'Message',
request_id: requestId,
message: {
id: `${requestId}-final`,
role: 'assistant',
metadata: { userVisible: true },
content: [{
type: 'text',
text: '页面已完成:https://example.com/MindSpace/user-1/public/retry.html',
}],
},
}),
eventFrame({
type: 'Finish',
request_id: requestId,
token_state: { inputTokens: 1, outputTokens: 2 },
}),
].join('');
const service = createBoundWechatService({
token,
config: { requireFreshPageThumbnail: true },
startAgentSession: async () => {
startedSessions += 1;
return { id: 'session-2' };
},
userAuth: {
async getWechatAgentRoute() {
return routeCleared ? null : { agentSessionId: 'session-1' };
},
async clearWechatAgentRoute() {
routeCleared = true;
},
async upsertWechatAgentRoute({ agentSessionId }) {
assert.equal(agentSessionId, 'session-2');
},
async resolveWorkingDir() {
return workspaceRoot;
},
async getUserPublishLayout() {
return {
publishDir: workspaceRoot,
displayName: 'John',
username: 'john',
slug: 'john',
constraints: null,
};
},
},
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === `/sessions/${sessionId}/reply`) {
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
if (sessionId === 'session-1') {
fs.writeFileSync(htmlPath, firstHtml, 'utf8');
} else {
fs.writeFileSync(htmlPath, retryHtml, 'utf8');
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage);
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/sessions/session-1/events') {
return new Response(
replyEvents({ requestId: 'req-thumbnail-first', html: firstHtml }),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-2/events') {
return new Response(
replyEvents({ requestId: 'req-thumbnail-retry', html: retryHtml, includeImage: true }),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
throw new Error(`unexpected session path: ${sessionId} ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
wechatPayloads.push(JSON.parse(init.body));
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = (() => {
const ids = ['req-thumbnail-first', 'req-thumbnail-retry'];
return () => ids.shift() ?? 'req-thumbnail-retry';
})();
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '生成一个活动页面,只要文字,不要正文图片' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
assert.equal(result.status, 200);
await result.task;
assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'retry.thumbnail.svg')), true);
} finally {
crypto.randomUUID = originalRandomUuid;
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
assert.equal(routeCleared, true);
assert.equal(startedSessions, 1);
assert.equal(wechatPayloads.length, 1);
assert.equal(wechatPayloads[0].msgtype, 'text');
assert.match(wechatPayloads[0].text.content, /retry\.html/);
assert.doesNotMatch(wechatPayloads[0].text.content, /没有完成服务号要求的本轮新缩略图/);
});
test('wechat mp standalone image intent sends a native image message and text', async () => {
const token = 'token';
const timestamp = '1710000000';