fix(wechat): enforce current-turn image paths in page delivery
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:
john
2026-08-27 12:54:46 +08:00
parent aecde46ff6
commit 760a1760ae
7 changed files with 442 additions and 9 deletions
+205
View File
@@ -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(/&quot;/g, '"')
.replace(/&#39;/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,
};
+71
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+32 -2
View File
@@ -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 };
}
+34
View File
@@ -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 ?? [],
};
}
+28
View File
@@ -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' },