diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 73f126b..5ba17d0 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -343,6 +343,32 @@ function isSuspiciousBareCompletionReply(reply, intent) { return !hasAnyToolRequest(reply?.messages ?? []); } +function looksLikePublishSuccessClaim(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized); +} + +async function hasAnyValidPublishedHtmlLink(text, linkExists) { + const value = String(text ?? ''); + for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) { + try { + if (await linkExists(match[0])) return true; + } catch { + // treat lookup failures as missing links + } + } + return false; +} + +async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = defaultPublicHtmlLinkExists } = {}) { + if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; + const text = String(reply?.text ?? '').trim(); + if (!looksLikePublishSuccessClaim(text)) return false; + if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false; + return !(await hasAnyValidPublishedHtmlLink(text, linkExists)); +} + function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) { const owner = path.basename(path.resolve(workingDir)); const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/'); @@ -371,13 +397,25 @@ function ensurePublicHtmlArtifact(htmlPath, workingDir) { }; } -async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) { - const baseText = String(reply?.text ?? '').trim(); +function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) { + const artifacts = []; const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []); for (const target of htmlTargets) { const artifact = ensurePublicHtmlArtifact(target, workingDir); if (!artifact) continue; - const url = buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl); + artifacts.push({ + ...artifact, + url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl), + }); + } + return artifacts; +} + +async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) { + const baseText = String(reply?.text ?? '').trim(); + const artifacts = collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }); + for (const artifact of artifacts) { + const url = artifact.url; if (baseText.includes(url)) return baseText; return baseText ? `${baseText}\n\n查看页面:\n${url}` @@ -520,6 +558,18 @@ export async function guardMissingPublicHtmlLinks( return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value); } +function downgradePrematurePublishClaims(text) { + const value = String(text ?? ''); + if (!value.includes('页面生成未完成')) return value; + return value + .replace(/(^|\n)([^\n]*页面都已经成功发布了[^\n]*)(?=\n|$)/g, '$1页面暂未生成完成,这次我先不发送失效链接。') + .replace(/(^|\n)([^\n]*主题页面已发布[^\n]*)(?=\n|$)/g, '$1🌴 夏日主题页面生成未完成') + .replace(/页面已创建完毕/g, '页面生成未完成') + .replace(/页面已创建/g, '页面生成未完成') + .replace(/发布成功/g, '生成未完成') + .replace(/已发布成功/g, '生成未完成'); +} + export { maybeAttachPublishedHtmlLink }; function isQuestionStatusProbe(text) { @@ -1089,9 +1139,21 @@ export function createWechatMpService({ }; }; - const sendCustomerServiceText = async (openid, content, user = null) => { + const sendCustomerServiceText = async (openid, content, user = null, { verifiedHtmlUrls = [] } = {}) => { const formatted = formatWechatOutboundText(content, user); - const guarded = await guardMissingPublicHtmlLinks(formatted, { linkExists }); + const verifiedUrlSet = new Set( + verifiedHtmlUrls + .map((url) => String(url ?? '').trim()) + .filter(Boolean), + ); + const guarded = downgradePrematurePublishClaims( + await guardMissingPublicHtmlLinks(formatted, { + linkExists: async (url) => { + if (verifiedUrlSet.has(String(url ?? '').trim())) return true; + return linkExists(url); + }, + }), + ); const chunks = splitWechatText(guarded); const accessToken = await getStableAccessToken(); for (const chunk of chunks) { @@ -1383,17 +1445,27 @@ export function createWechatMpService({ buildWechatAgentPrompt(intent), buildIntentMetadata(intent), ); - if (isSuspiciousBareCompletionReply(reply, intent)) { + if ( + isSuspiciousBareCompletionReply(reply, intent) || + (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists })) + ) { throw new Error('stale_session_poisoned_completion'); } if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId); } - const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { - workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)), + const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); + const publishedArtifacts = collectPublishedHtmlArtifacts(reply, { + workingDir, publicBaseUrl: config.publicBaseUrl, }); - await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user); + const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { + workingDir, + publicBaseUrl: config.publicBaseUrl, + }); + await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { + verifiedHtmlUrls: publishedArtifacts.map((artifact) => artifact.url), + }); return { sessionId }; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -1416,17 +1488,27 @@ export function createWechatMpService({ buildWechatAgentPrompt(intent), buildIntentMetadata(intent), ); - if (isSuspiciousBareCompletionReply(reply, intent)) { + if ( + isSuspiciousBareCompletionReply(reply, intent) || + (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists })) + ) { throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试'); } if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId); } - const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { - workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)), + const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); + const publishedArtifacts = collectPublishedHtmlArtifacts(reply, { + workingDir, publicBaseUrl: config.publicBaseUrl, }); - await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user); + const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { + workingDir, + publicBaseUrl: config.publicBaseUrl, + }); + await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { + verifiedHtmlUrls: publishedArtifacts.map((artifact) => artifact.url), + }); return { sessionId }; } throw err; diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 52c47fd..3646877 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -138,6 +138,79 @@ test('guardMissingPublicHtmlLinks blocks missing MindSpace public html links', a assert.match(guarded, /missing\.html/); }); +test('wechat mp service rejects premature publish success copy when page link is blocked', async () => { + const wechatCalls = []; + const service = createBoundWechatService({ + config: { + publicBaseUrl: 'https://m.tkmind.cn', + }, + sessionApiFetch: async (sessionId, pathname) => { + assert.equal(sessionId, 'session-1'); + if (pathname === '/sessions/session-1/events') { + return new Response( + [ + 'data: {"type":"Message","request_id":"req-1","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"好消息!m.tkmind.cn 其实是可以访问的,页面都已经成功发布了!以下是您的夏日主题页面:\\n\\n🌴 夏日主题页面已发布\\n\\nhttps://m.tkmind.cn/MindSpace/john/public/summer-breeze-journal.html"}]}}\n\n', + 'data: {"type":"Finish","request_id":"req-1","token_state":{"inputTokens":2,"outputTokens":2}}\n\n', + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname === '/sessions/session-1/reply') { + return new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected session api path: ${sessionId} ${pathname}`); + }, + wechatFetch: async (url, init = {}) => { + wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]); + 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')) { + return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected wechat url: ${url}`); + }, + userAuth: { + async resolveWorkingDir() { + return '/tmp/user-1'; + }, + }, + }); + const originalRandomUuid = crypto.randomUUID; + crypto.randomUUID = () => 'req-1'; + try { + const result = await service.handleInboundMessage(inboundXml({ content: '帮我生成一个夏日页面' }), { + timestamp: '1710000000', + nonce: 'nonce', + signature: signatureFor('token', '1710000000', 'nonce'), + }); + assert.equal(result.status, 200); + await result.task; + } finally { + crypto.randomUUID = originalRandomUuid; + } + + 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, /旧指令污染|稍后重试|转发到专属 Agent 失败/); +}); + test('maybeAttachPublishedHtmlLink copies generated root html into public and returns link', async (t) => { const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-public-'); const htmlPath = `${workspaceRoot}/hello.html`; @@ -388,6 +461,10 @@ test('wechat mp service strips markdown emphasis around outbound links', async ( } throw new Error(`unexpected wechat url: ${url}`); }, + linkExists: async (urlText) => { + if (!String(urlText).includes('/summer-breeze-journal.html')) return false; + return fs.existsSync(htmlPath); + }, }); const originalRandomUuid = crypto.randomUUID; @@ -452,6 +529,10 @@ test('wechat mp service does not send stale page links from unscoped conversatio } throw new Error(`unexpected wechat url: ${url}`); }, + linkExists: async (urlText) => { + if (!String(urlText).includes('/summer-breeze-journal.html')) return false; + return fs.existsSync(htmlPath); + }, }); const originalRandomUuid = crypto.randomUUID; @@ -696,6 +777,10 @@ test('wechat mp service routes text to dedicated session and sends customer serv } throw new Error(`unexpected wechat url: ${url}`); }, + linkExists: async (urlText) => { + if (!String(urlText).includes('/summer-breeze-journal.html')) return false; + return fs.existsSync(htmlPath); + }, }); const originalRandomUuid = crypto.randomUUID; @@ -808,6 +893,10 @@ test('wechat mp service rewrites internal wx username mentions that appear mid-m } throw new Error(`unexpected wechat url: ${url}`); }, + linkExists: async (urlText) => { + if (!String(urlText).includes('/summer-breeze-journal.html')) return false; + return fs.existsSync(htmlPath); + }, }); const originalRandomUuid = crypto.randomUUID; @@ -1284,6 +1373,10 @@ test('wechat mp service recreates poisoned dedicated session after bare completi } throw new Error(`unexpected wechat url: ${url}`); }, + linkExists: async (urlText) => { + if (!String(urlText).includes('/summer-breeze-journal.html')) return false; + return fs.existsSync(htmlPath); + }, }); const originalRandomUuid = crypto.randomUUID; @@ -1314,6 +1407,181 @@ test('wechat mp service recreates poisoned dedicated session after bare completi assert.match(payload.text.content, /页面生成未完成|hello\.html/); }); +test('wechat mp service recreates poisoned dedicated session after publish-success claim without html evidence', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const wechatCalls = []; + let routeCleared = false; + let started = false; + const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-publish-claim-'); + const htmlPath = path.join(workspaceRoot, 'public', 'summer-breeze-journal.html'); + + const service = createWechatMpService({ + config: { + enabled: true, + appId: 'wx123', + appSecret: 'secret', + token, + publicBaseUrl: 'https://m.tkmind.cn', + bindPath: '/auth/wechat/authorize?intent=login', + ackText: 'ack', + unsupportedText: 'unsupported', + unboundTextPrefix: '请先绑定', + progressDelayMs: 0, + }, + userAuth: { + async findWechatUserByOpenid() { + return { userId: 'user-1', status: 'active', nickname: '毕升' }; + }, + async getWechatAgentRoute() { + return routeCleared ? null : { agentSessionId: 'session-1' }; + }, + async clearWechatAgentRoute() { + routeCleared = true; + }, + async canUseChat() { + return { ok: true }; + }, + async resolveWorkingDir() { + return workspaceRoot; + }, + async getAgentSessionPolicy() { + return { + enableContextMemory: false, + extensionOverrides: [ + { + type: 'platform', + name: 'developer', + available_tools: ['write'], + }, + ], + unrestricted: false, + }; + }, + async getUserPublishLayout() { + return { publishDir: workspaceRoot, displayName: 'John', username: 'john', slug: 'john', constraints: null }; + }, + async registerAgentSession() {}, + async upsertWechatAgentRoute({ agentSessionId }) { + assert.equal(agentSessionId, 'session-2'); + }, + async billSessionUsage() {}, + async recordWechatMpMessage() { + return { inserted: true }; + }, + async finishWechatMpMessage() {}, + async insertWechatMpMessageDetail() {}, + }, + apiFetch: async (pathname, init = {}) => { + if (pathname === '/agent/start') { + started = true; + const body = JSON.parse(init.body); + assert.equal(body.working_dir, workspaceRoot); + return new Response(JSON.stringify({ id: 'session-2' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected api path: ${pathname}`); + }, + sessionApiFetch: async (sessionId, pathname) => { + if (pathname === `/sessions/${sessionId}/extensions`) { + return new Response(JSON.stringify({ extensions: [{ name: 'developer', available_tools: ['write'] }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (pathname === '/agent/update_working_dir' || pathname === '/agent/update_session' || pathname === '/agent/restart') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/add_extension') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === `/sessions/${sessionId}`) { + return new Response(JSON.stringify({ working_dir: workspaceRoot }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (pathname === `/sessions/${sessionId}/reply`) { + if (sessionId === 'session-2') { + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, '