fix(wechat): enforce current-turn image paths in page delivery
Memind CI / Test, build, and release guards (push) Successful in 6m58s
Memind CI / Test, build, and release guards (push) Successful in 6m58s
Materialize wechat-mp uploads into dated public/images paths during vision preprocessing and fail closed when delivered HTML references stale workspace images instead of the current turn allowlist. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { extractImageAssetKey } from './chat-image-turn-scope.mjs';
|
||||
|
||||
const WECHAT_MP_PUBLIC_PATH_RE = /\/public\/(wechat-mp\/[^?#\s"'<>]+)/i;
|
||||
const MINDSPACE_PUBLIC_PATH_RE =
|
||||
/\/MindSpace\/[0-9a-f-]{36}\/public\/(wechat-mp\/[^?#\s"'<>]+|images\/[^?#\s"'<>]+)/i;
|
||||
const HTML_IMG_SRC_RE = /\bsrc\s*=\s*["']([^"']+)["']/gi;
|
||||
const HTML_COVER_JSON_RE = /name=["']mindspace-cover["']\s+content=['"]([^'"]+)['"]/i;
|
||||
|
||||
function formatUtcDateStamp(date = new Date()) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function extractPublicZoneRelativePath(rawUrl, userId = '') {
|
||||
const value = String(rawUrl ?? '').trim();
|
||||
if (!value) return null;
|
||||
const mindspaceMatch = value.match(MINDSPACE_PUBLIC_PATH_RE);
|
||||
if (mindspaceMatch?.[1]) {
|
||||
return `public/${mindspaceMatch[1]}`;
|
||||
}
|
||||
const wechatMatch = value.match(WECHAT_MP_PUBLIC_PATH_RE);
|
||||
if (wechatMatch?.[1]) {
|
||||
return `public/${wechatMatch[1]}`;
|
||||
}
|
||||
if (value.startsWith('public/wechat-mp/') || value.startsWith('public/images/')) {
|
||||
return value;
|
||||
}
|
||||
if (value.startsWith('wechat-mp/') || value.startsWith('images/')) {
|
||||
return `public/${value}`;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(
|
||||
value,
|
||||
value.startsWith('/') ? 'http://local' : undefined,
|
||||
);
|
||||
const pathname = parsed.pathname;
|
||||
const publicIndex = pathname.indexOf('/public/');
|
||||
if (publicIndex >= 0) {
|
||||
const tail = pathname.slice(publicIndex + '/public/'.length);
|
||||
if (tail.startsWith('wechat-mp/') || tail.startsWith('images/')) {
|
||||
return `public/${tail}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
void userId;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function materializeWechatPublicImageForEmbed({
|
||||
publishDir,
|
||||
rawUrl,
|
||||
buffer = null,
|
||||
mimeType = 'image/jpeg',
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
const publicRelativePath = extractPublicZoneRelativePath(rawUrl);
|
||||
if (!publicRelativePath || !String(publishDir ?? '').trim()) {
|
||||
return null;
|
||||
}
|
||||
const sourceAbs = path.join(publishDir, publicRelativePath);
|
||||
let sourceBuffer = buffer;
|
||||
if (!sourceBuffer) {
|
||||
try {
|
||||
if (!fs.existsSync(sourceAbs) || !fs.statSync(sourceAbs).isFile()) {
|
||||
return null;
|
||||
}
|
||||
sourceBuffer = fs.readFileSync(sourceAbs);
|
||||
if (!mimeType) mimeType = 'image/jpeg';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!Buffer.isBuffer(sourceBuffer) || sourceBuffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (publicRelativePath.startsWith('public/images/')) {
|
||||
const embedPath = publicRelativePath.slice('public/'.length);
|
||||
return {
|
||||
publicRelativePath,
|
||||
relativeEmbedPath: embedPath,
|
||||
embedUrl: embedPath,
|
||||
assetKeys: collectEmbedAssetKeys([embedPath, publicRelativePath, rawUrl]),
|
||||
materialized: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!publicRelativePath.startsWith('public/wechat-mp/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateDir = formatUtcDateStamp(now);
|
||||
const hash = crypto.createHash('md5').update(sourceBuffer).digest('hex').slice(0, 8);
|
||||
const basename = path.posix.basename(publicRelativePath);
|
||||
const destPublicRelativePath = `public/images/${dateDir}/${hash}-${basename}`;
|
||||
const destAbs = path.join(publishDir, destPublicRelativePath);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
||||
if (!fs.existsSync(destAbs)) {
|
||||
fs.writeFileSync(destAbs, sourceBuffer);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const embedPath = destPublicRelativePath.slice('public/'.length);
|
||||
return {
|
||||
publicRelativePath: destPublicRelativePath,
|
||||
relativeEmbedPath: embedPath,
|
||||
embedUrl: embedPath,
|
||||
assetKeys: collectEmbedAssetKeys([embedPath, destPublicRelativePath, rawUrl]),
|
||||
materialized: true,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectEmbedAssetKeys(values = []) {
|
||||
const keys = new Set();
|
||||
for (const value of values) {
|
||||
const key = extractImageAssetKey(value);
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function buildAllowedPageImageEmbedKeys({
|
||||
publishDir,
|
||||
imageUrls = [],
|
||||
userId = '',
|
||||
} = {}) {
|
||||
const allowed = new Set();
|
||||
for (const rawUrl of imageUrls) {
|
||||
const materialized = materializeWechatPublicImageForEmbed({
|
||||
publishDir,
|
||||
rawUrl,
|
||||
userId,
|
||||
});
|
||||
if (materialized?.assetKeys) {
|
||||
for (const key of materialized.assetKeys) allowed.add(key);
|
||||
} else {
|
||||
for (const key of collectEmbedAssetKeys([rawUrl])) allowed.add(key);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
export function extractHtmlImageSourceKeys(html) {
|
||||
const keys = new Set();
|
||||
const value = String(html ?? '');
|
||||
if (!value) return keys;
|
||||
|
||||
for (const match of value.matchAll(HTML_IMG_SRC_RE)) {
|
||||
for (const key of collectEmbedAssetKeys([match[1]])) keys.add(key);
|
||||
}
|
||||
|
||||
const coverMatch = value.match(HTML_COVER_JSON_RE);
|
||||
if (coverMatch?.[1]) {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
coverMatch[1]
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'"),
|
||||
);
|
||||
for (const key of collectEmbedAssetKeys([parsed?.cover, parsed?.image])) {
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
} catch {
|
||||
for (const key of collectEmbedAssetKeys([coverMatch[1]])) keys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function verifyHtmlImageSourcesAllowed(html, allowedKeys) {
|
||||
const allowed = allowedKeys instanceof Set ? allowedKeys : new Set(allowedKeys);
|
||||
if (allowed.size === 0) {
|
||||
return { ok: true, reason: null, offendingKeys: [] };
|
||||
}
|
||||
const found = extractHtmlImageSourceKeys(html);
|
||||
if (found.size === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing_required_images',
|
||||
offendingKeys: [],
|
||||
};
|
||||
}
|
||||
const offendingKeys = [...found].filter((key) => !allowed.has(key));
|
||||
if (offendingKeys.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'stale_image_source',
|
||||
offendingKeys,
|
||||
};
|
||||
}
|
||||
return { ok: true, reason: null, offendingKeys: [] };
|
||||
}
|
||||
|
||||
export const chatImageMaterializeInternals = {
|
||||
formatUtcDateStamp,
|
||||
HTML_IMG_SRC_RE,
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildAllowedPageImageEmbedKeys,
|
||||
extractHtmlImageSourceKeys,
|
||||
materializeWechatPublicImageForEmbed,
|
||||
verifyHtmlImageSourcesAllowed,
|
||||
} from './chat-image-materialize.mjs';
|
||||
|
||||
test('materializeWechatPublicImageForEmbed copies wechat-mp images into public/images date dir', (t) => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chat-image-materialize-'));
|
||||
t.after(() => fs.rmSync(publishDir, { recursive: true, force: true }));
|
||||
const sourceRel = 'public/wechat-mp/sample-photo.jpg';
|
||||
const sourceAbs = path.join(publishDir, sourceRel);
|
||||
fs.mkdirSync(path.dirname(sourceAbs), { recursive: true });
|
||||
fs.writeFileSync(sourceAbs, Buffer.from('fresh-image-bytes'));
|
||||
|
||||
const result = materializeWechatPublicImageForEmbed({
|
||||
publishDir,
|
||||
rawUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/wechat-mp/sample-photo.jpg',
|
||||
now: new Date('2026-08-27T04:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.ok(result?.materialized);
|
||||
assert.match(result.relativeEmbedPath, /^images\/2026-08-27\/[a-f0-9]{8}-sample-photo\.jpg$/);
|
||||
assert.equal(fs.existsSync(path.join(publishDir, result.publicRelativePath)), true);
|
||||
});
|
||||
|
||||
test('verifyHtmlImageSourcesAllowed rejects stale workspace images for the current turn', () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chat-image-verify-'));
|
||||
const sourceAbs = path.join(publishDir, 'public/wechat-mp/new.jpg');
|
||||
fs.mkdirSync(path.dirname(sourceAbs), { recursive: true });
|
||||
fs.writeFileSync(sourceAbs, Buffer.from('new-turn-image'));
|
||||
const allowed = buildAllowedPageImageEmbedKeys({
|
||||
publishDir,
|
||||
imageUrls: ['https://m.tkmind.cn/MindSpace/user-1/public/wechat-mp/new.jpg'],
|
||||
});
|
||||
const html = [
|
||||
'<html><body>',
|
||||
'<img src="images/2026-08-22/old-photo.jpg" alt="old">',
|
||||
'</body></html>',
|
||||
].join('');
|
||||
const verification = verifyHtmlImageSourcesAllowed(html, allowed);
|
||||
assert.equal(verification.ok, false);
|
||||
assert.equal(verification.reason, 'stale_image_source');
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('verifyHtmlImageSourcesAllowed accepts materialized current-turn image paths', () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chat-image-verify-ok-'));
|
||||
const sourceAbs = path.join(publishDir, 'public/wechat-mp/new.jpg');
|
||||
fs.mkdirSync(path.dirname(sourceAbs), { recursive: true });
|
||||
fs.writeFileSync(sourceAbs, Buffer.from('new-turn-image'));
|
||||
const materialized = materializeWechatPublicImageForEmbed({
|
||||
publishDir,
|
||||
rawUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/wechat-mp/new.jpg',
|
||||
now: new Date('2026-08-27T04:00:00.000Z'),
|
||||
});
|
||||
const allowed = buildAllowedPageImageEmbedKeys({
|
||||
publishDir,
|
||||
imageUrls: ['https://m.tkmind.cn/MindSpace/user-1/public/wechat-mp/new.jpg'],
|
||||
});
|
||||
const html = `<html><body><img src="${materialized.relativeEmbedPath}" alt="new"></body></html>`;
|
||||
const keys = extractHtmlImageSourceKeys(html);
|
||||
assert.equal(verifyHtmlImageSourcesAllowed(html, allowed).ok, true);
|
||||
assert.ok(keys.size > 0);
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
});
|
||||
+36
-3
@@ -39,6 +39,10 @@ import {
|
||||
extractCurrentTurnImageUrls,
|
||||
scrubConversationHistoricalImageAttachments,
|
||||
} from './chat-image-turn-scope.mjs';
|
||||
import {
|
||||
collectEmbedAssetKeys,
|
||||
materializeWechatPublicImageForEmbed,
|
||||
} from './chat-image-materialize.mjs';
|
||||
import { repairConversationToolHistory } from './chat-tool-history-repair.mjs';
|
||||
import { buildVisionThumbnailBuffer } from './vision-image-thumb.mjs';
|
||||
import {
|
||||
@@ -864,6 +868,7 @@ export async function buildVisionPayload({
|
||||
userMessage,
|
||||
userId,
|
||||
publishLayout,
|
||||
publishDir = null,
|
||||
localFetchAsset,
|
||||
llmProviderService,
|
||||
imgproxySigner = null,
|
||||
@@ -922,6 +927,14 @@ export async function buildVisionPayload({
|
||||
const parsed = new URL(rawUrl);
|
||||
relativePath = parsed.pathname + parsed.search;
|
||||
} catch { /* keep rawUrl */ }
|
||||
const materialized = publishDir
|
||||
? materializeWechatPublicImageForEmbed({
|
||||
publishDir,
|
||||
rawUrl,
|
||||
buffer,
|
||||
mimeType,
|
||||
})
|
||||
: null;
|
||||
const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId, publishLayout);
|
||||
let visionBuffer = buffer;
|
||||
let visionMimeType = mimeType;
|
||||
@@ -940,7 +953,12 @@ export async function buildVisionPayload({
|
||||
data: visionBuffer.toString('base64'),
|
||||
relativePath,
|
||||
rawUrl,
|
||||
embedUrl: publicStandardUrl ?? relativePath,
|
||||
embedUrl: materialized?.embedUrl ?? publicStandardUrl ?? relativePath,
|
||||
allowedEmbedKeys: materialized?.assetKeys ?? collectEmbedAssetKeys([
|
||||
publicStandardUrl,
|
||||
relativePath,
|
||||
rawUrl,
|
||||
]),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Vision image fetch skipped:', err instanceof Error ? err.message : err);
|
||||
@@ -1009,6 +1027,11 @@ export async function buildVisionPayload({
|
||||
const canonicalImageUrls = imageItems
|
||||
.map((item) => item.embedUrl ?? item.rawUrl)
|
||||
.filter((url) => typeof url === 'string' && url.trim());
|
||||
const allowedPageImageEmbedKeys = [
|
||||
...new Set(
|
||||
imageItems.flatMap((item) => [...(item.allowedEmbedKeys ?? [])]),
|
||||
),
|
||||
];
|
||||
|
||||
return {
|
||||
userMessage: detachCurrentTurnImagesForTextProvider(
|
||||
@@ -1019,6 +1042,9 @@ export async function buildVisionPayload({
|
||||
...(userMessage.metadata ?? {}),
|
||||
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
|
||||
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
|
||||
...(allowedPageImageEmbedKeys.length
|
||||
? { allowedPageImageEmbedKeys }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
canonicalImageUrls,
|
||||
@@ -1618,11 +1644,12 @@ export function createTkmindProxy({
|
||||
// Step 2 — Inject Qwen's text description + server-relative image paths into the
|
||||
// user_message that goes to DeepSeek via Goose. DeepSeek retains full
|
||||
// tool-calling capability (write_file, etc.) and creates the page properly.
|
||||
async function buildVisionBody(userMessage, userId, publishLayout) {
|
||||
async function buildVisionBody(userMessage, userId, publishLayout, publishDir = null) {
|
||||
return buildVisionPayload({
|
||||
userMessage,
|
||||
userId,
|
||||
publishLayout,
|
||||
publishDir,
|
||||
localFetchAsset,
|
||||
llmProviderService,
|
||||
imgproxySigner,
|
||||
@@ -2039,7 +2066,13 @@ export function createTkmindProxy({
|
||||
let finalUserMessage = userMessage;
|
||||
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
|
||||
const publishLayout = await userAuth.getUserPublishLayout(userId).catch(() => null);
|
||||
const visionResult = await buildVisionBody(userMessage, userId, publishLayout).catch(() => null);
|
||||
const publishDir = await userAuth.resolveWorkingDir(userId).catch(() => null);
|
||||
const visionResult = await buildVisionBody(
|
||||
userMessage,
|
||||
userId,
|
||||
publishLayout,
|
||||
publishDir,
|
||||
).catch(() => null);
|
||||
if (visionResult?.userMessage) {
|
||||
finalUserMessage = visionResult.userMessage;
|
||||
}
|
||||
|
||||
+36
-4
@@ -1,4 +1,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
buildAllowedPageImageEmbedKeys,
|
||||
} from './chat-image-materialize.mjs';
|
||||
import path from 'node:path';
|
||||
import { fetch as undiciFetch } from 'undici';
|
||||
import { developerToolsFromPolicy } from './capabilities.mjs';
|
||||
@@ -1121,6 +1124,20 @@ export function resolveWechatRecentMediaPublicUrl(recentMediaEntry) {
|
||||
return String(recentMediaEntry.items.at(-1)?.media?.publicUrl ?? '').trim();
|
||||
}
|
||||
|
||||
export function collectIntentImageUrls(intent, { mediaAnalysisEnabled = false } = {}) {
|
||||
if (!mediaAnalysisEnabled) return [];
|
||||
const mediaPublicUrl = intent?.media?.publicUrl || null;
|
||||
const mediaItems = Array.isArray(intent?.recentMediaItems) && intent.recentMediaItems.length > 0
|
||||
? intent.recentMediaItems
|
||||
: mediaPublicUrl
|
||||
? [{ media: intent.media, attachment: intent.attachment ?? null }]
|
||||
: [];
|
||||
return mediaItems
|
||||
.filter((item) => !item.attachment)
|
||||
.map((item) => String(item?.media?.publicUrl ?? '').trim())
|
||||
.filter((url, index, values) => url && values.indexOf(url) === index);
|
||||
}
|
||||
|
||||
export function prepareWechatIntentForHistoricalImageRetry(intent, { fallbackImageUrl = '' } = {}) {
|
||||
if (!intent || typeof intent !== 'object') return intent;
|
||||
const imageUrl = String(intent.media?.publicUrl ?? fallbackImageUrl ?? '').trim();
|
||||
@@ -2561,16 +2578,13 @@ export function createWechatMpService({
|
||||
pgRequired = false,
|
||||
} = {},
|
||||
) => {
|
||||
const imageUrls = collectIntentImageUrls(intent, { mediaAnalysisEnabled });
|
||||
const mediaPublicUrl = intent.media?.publicUrl || null;
|
||||
const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0
|
||||
? intent.recentMediaItems
|
||||
: mediaPublicUrl
|
||||
? [{ media: intent.media, attachment: intent.attachment ?? null }]
|
||||
: [];
|
||||
const imageUrls = mediaItems
|
||||
.filter((item) => !item.attachment)
|
||||
.map((item) => String(item?.media?.publicUrl ?? '').trim())
|
||||
.filter((url, index, values) => url && values.indexOf(url) === index);
|
||||
const fileAttachments = mediaItems
|
||||
.filter((item) => item.attachment?.filename && item.media?.publicUrl)
|
||||
.map((item) => ({
|
||||
@@ -3071,6 +3085,14 @@ export function createWechatMpService({
|
||||
: [];
|
||||
|
||||
if (wechatIntent.kind === 'page.generate') {
|
||||
const intentImageUrls = collectIntentImageUrls(intent, { mediaAnalysisEnabled });
|
||||
const allowedImageEmbedKeys = intentImageUrls.length > 0
|
||||
? buildAllowedPageImageEmbedKeys({
|
||||
publishDir: userPublishDir,
|
||||
imageUrls: intentImageUrls,
|
||||
userId: user.userId,
|
||||
})
|
||||
: null;
|
||||
const pageOutcome = resolvePageGenerateOutcome({
|
||||
reply,
|
||||
confirmedArtifacts,
|
||||
@@ -3084,6 +3106,7 @@ export function createWechatMpService({
|
||||
publishDir: userPublishDir,
|
||||
},
|
||||
wechatCursorChannel: isWechatCursorChannelReply(reply),
|
||||
allowedImageEmbedKeys,
|
||||
});
|
||||
if (pageOutcome.action === 'session_retry') {
|
||||
if (sessionPageContinuation && pageAttempt < maxImmediateContextPageAttempts) {
|
||||
@@ -3479,6 +3502,14 @@ export function createWechatMpService({
|
||||
: [];
|
||||
|
||||
if (wechatIntent.kind === 'page.generate') {
|
||||
const intentImageUrls = collectIntentImageUrls(intent, { mediaAnalysisEnabled });
|
||||
const allowedImageEmbedKeys = intentImageUrls.length > 0
|
||||
? buildAllowedPageImageEmbedKeys({
|
||||
publishDir: userPublishDir,
|
||||
imageUrls: intentImageUrls,
|
||||
userId: user.userId,
|
||||
})
|
||||
: null;
|
||||
const pageOutcome = resolvePageGenerateOutcome({
|
||||
reply,
|
||||
confirmedArtifacts,
|
||||
@@ -3492,6 +3523,7 @@ export function createWechatMpService({
|
||||
publishDir: userPublishDir,
|
||||
},
|
||||
wechatCursorChannel: isWechatCursorChannelReply(reply),
|
||||
allowedImageEmbedKeys,
|
||||
});
|
||||
if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') {
|
||||
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildPagePublishFailureText } from '../prompts/page-generate.mjs';
|
||||
import { selectSendableHtmlArtifacts, verifyPageArtifactContent } from '../verify/page-artifact.mjs';
|
||||
import { selectSendableHtmlArtifacts, verifyPageArtifactContent, verifyPageArtifactImageSources } from '../verify/page-artifact.mjs';
|
||||
import { repairArtifactSharePreview } from '../verify/share-preview-repair.mjs';
|
||||
import { filterSharePreviewReadyArtifacts, verifyArtifactSharePreview } from '../verify/share-preview.mjs';
|
||||
|
||||
@@ -7,6 +7,7 @@ export function evaluatePageGenerateSendableArtifacts({
|
||||
verifiedArtifacts = [],
|
||||
confirmedArtifacts = [],
|
||||
repairContext = {},
|
||||
allowedImageEmbedKeys = null,
|
||||
} = {}) {
|
||||
const publishDir = String(repairContext.publishDir ?? '').trim();
|
||||
const previewOptions = { publishDir };
|
||||
@@ -18,7 +19,13 @@ export function evaluatePageGenerateSendableArtifacts({
|
||||
const verified = sendable.filter((artifact) =>
|
||||
verifyPageArtifactContent(artifact, previewOptions).ok,
|
||||
);
|
||||
const candidates = verified.length > 0 ? verified : sendable;
|
||||
const imageVerified = (verified.length > 0 ? verified : sendable).filter((artifact) =>
|
||||
verifyPageArtifactImageSources(artifact, {
|
||||
publishDir,
|
||||
allowedImageEmbedKeys,
|
||||
}).ok,
|
||||
);
|
||||
const candidates = imageVerified.length > 0 ? imageVerified : [];
|
||||
const ready = filterSharePreviewReadyArtifacts(candidates, previewOptions);
|
||||
if (ready.length > 0) return ready;
|
||||
|
||||
@@ -48,6 +55,7 @@ export function resolvePageGenerateOutcome({
|
||||
topic = '',
|
||||
repairContext = {},
|
||||
wechatCursorChannel = false,
|
||||
allowedImageEmbedKeys = null,
|
||||
}) {
|
||||
const context = {
|
||||
topic: String(topic || repairContext.topic || '').trim(),
|
||||
@@ -59,8 +67,30 @@ export function resolvePageGenerateOutcome({
|
||||
verifiedArtifacts,
|
||||
confirmedArtifacts,
|
||||
repairContext: context,
|
||||
allowedImageEmbedKeys,
|
||||
});
|
||||
|
||||
if (sendable.length === 0 && allowedImageEmbedKeys instanceof Set && allowedImageEmbedKeys.size > 0) {
|
||||
const staleCandidate = selectSendableHtmlArtifacts({
|
||||
verifiedArtifacts,
|
||||
confirmedArtifacts,
|
||||
publishDir: context.publishDir,
|
||||
}).find((artifact) =>
|
||||
verifyPageArtifactContent(artifact, previewOptions).ok
|
||||
&& !verifyPageArtifactImageSources(artifact, {
|
||||
publishDir: context.publishDir,
|
||||
allowedImageEmbedKeys,
|
||||
}).ok,
|
||||
);
|
||||
if (staleCandidate) {
|
||||
return {
|
||||
action: 'fail',
|
||||
failureText: buildPagePublishFailureText(),
|
||||
reason: 'stale_image_source',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (sendable.length > 0) {
|
||||
return { action: 'send', artifacts: sendable };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { verifyHtmlImageSourcesAllowed } from '../../chat-image-materialize.mjs';
|
||||
|
||||
const STUB_MARKERS = ['临时补出', '服务号兜底', '服务号自动补出简版页面'];
|
||||
|
||||
@@ -106,3 +107,36 @@ export function verifyPageArtifactContent(artifact, { minBytes = 512, publishDir
|
||||
}
|
||||
return { ok: true, reason: null };
|
||||
}
|
||||
|
||||
export function verifyPageArtifactImageSources(
|
||||
artifact,
|
||||
{
|
||||
publishDir = '',
|
||||
allowedImageEmbedKeys = null,
|
||||
} = {},
|
||||
) {
|
||||
const allowed = allowedImageEmbedKeys instanceof Set
|
||||
? allowedImageEmbedKeys
|
||||
: Array.isArray(allowedImageEmbedKeys)
|
||||
? new Set(allowedImageEmbedKeys)
|
||||
: null;
|
||||
if (!allowed || allowed.size === 0) {
|
||||
return { ok: true, reason: null, offendingKeys: [] };
|
||||
}
|
||||
const localPath = resolveArtifactLocalPath(artifact, publishDir);
|
||||
if (!localPath) {
|
||||
return { ok: false, reason: 'missing_file', offendingKeys: [] };
|
||||
}
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(localPath, 'utf8');
|
||||
} catch {
|
||||
return { ok: false, reason: 'missing_file', offendingKeys: [] };
|
||||
}
|
||||
const verification = verifyHtmlImageSourcesAllowed(content, allowed);
|
||||
return {
|
||||
ok: verification.ok,
|
||||
reason: verification.reason,
|
||||
offendingKeys: verification.offendingKeys ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from './verify/page-artifact.mjs';
|
||||
import { guardScheduleConfirmationReply, looksLikeScheduleConfirmation } from './handlers/schedule-guard.mjs';
|
||||
import { resolvePageGenerateOutcome } from './handlers/page-generate.mjs';
|
||||
import { buildAllowedPageImageEmbedKeys } from '../chat-image-materialize.mjs';
|
||||
import { buildGreetingText, resolveSyncReply } from './handlers/sync-replies.mjs';
|
||||
import { buildWechatAgentPrompt } from './prompts/chat-general.mjs';
|
||||
import {
|
||||
@@ -167,6 +168,33 @@ test('schedule confirmation guard rewrites agent pseudo-confirm when ITL enabled
|
||||
assert.doesNotMatch(guarded, /不能算设置成功/);
|
||||
});
|
||||
|
||||
test('resolvePageGenerateOutcome fails closed when html reuses stale image paths', () => {
|
||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-stale-image-'));
|
||||
const wechatMp = path.join(publishDir, 'public/wechat-mp/new.jpg');
|
||||
fs.mkdirSync(path.dirname(wechatMp), { recursive: true });
|
||||
fs.writeFileSync(wechatMp, Buffer.from('fresh-image'));
|
||||
const htmlPath = path.join(publishDir, 'public/page.html');
|
||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
htmlPath,
|
||||
`<!doctype html><html><head><meta name="description" content="摘要"><meta name="mindspace-cover" content='{"tag":"页面","cover":"images/2026-08-22/old.jpg"}'><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></head><body><main>${'x'.repeat(600)}<img src="images/2026-08-22/old.jpg"></main></body></html>`,
|
||||
'utf8',
|
||||
);
|
||||
const allowedImageEmbedKeys = buildAllowedPageImageEmbedKeys({
|
||||
publishDir,
|
||||
imageUrls: ['https://m.tkmind.cn/MindSpace/user-1/public/wechat-mp/new.jpg'],
|
||||
});
|
||||
const outcome = resolvePageGenerateOutcome({
|
||||
reply: { text: '页面完成' },
|
||||
confirmedArtifacts: [{ localPath: htmlPath, relativePath: 'public/page.html' }],
|
||||
repairContext: { publishDir },
|
||||
allowedImageEmbedKeys,
|
||||
});
|
||||
assert.equal(outcome.action, 'fail');
|
||||
assert.equal(outcome.reason, 'stale_image_source');
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('resolvePageGenerateOutcome fails when only stub artifacts exist', () => {
|
||||
const outcome = resolvePageGenerateOutcome({
|
||||
reply: { text: '页面已生成 https://m.tkmind.cn/MindSpace/u/public/tang-poem.html' },
|
||||
|
||||
Reference in New Issue
Block a user