diff --git a/server.mjs b/server.mjs index 5c1a97b..25ae4ca 100644 --- a/server.mjs +++ b/server.mjs @@ -537,12 +537,22 @@ async function bootstrapUserAuth() { config: WECHAT_MP_CONFIG, userAuth, apiFetch: tkmindProxy.apiFetch, + startAgentSession: ({ userId, workingDir, sessionPolicy }) => + tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }), sessionApiFetch: async (sessionId, pathname, init) => { const target = await tkmindProxy.resolveTarget(sessionId); return tkmindProxy.apiFetchTo(target, pathname, init); }, scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null, applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId), + refreshSessionSnapshot: + sessionSnapshotService?.isEnabled() + ? (sessionId, userId) => + sessionSnapshotService.refresh(sessionId, userId, async (pathname, init) => { + const target = await tkmindProxy.resolveTarget(sessionId); + return tkmindProxy.apiFetchTo(target, pathname, init); + }) + : null, }); userAuth.setRechargeNotifier(async ({ userId, title, body }) => { if (!wechatMpService?.enabled) return; diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 0aa4261..b523707 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -345,6 +345,51 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth, return primaryTarget; } + async function startSessionForUser( + userId, + { + workingDir, + sessionPolicy = null, + recipe = null, + } = {}, + ) { + const startTarget = await pickTarget(); + const upstream = await apiFetch(startTarget, apiSecret, '/agent/start', { + method: 'POST', + body: JSON.stringify({ + ...(workingDir ? { working_dir: workingDir } : {}), + enable_context_memory: sessionPolicy?.enableContextMemory, + ...(sessionPolicy?.extensionOverrides + ? { extension_overrides: sessionPolicy.extensionOverrides } + : {}), + ...(recipe ? { recipe } : {}), + }), + }); + const text = await upstream.text(); + if (!upstream.ok) { + throw new Error(text || `创建会话失败 (${upstream.status})`); + } + const session = JSON.parse(text); + if (!session?.id) { + throw new Error('创建会话失败:缺少 session id'); + } + await userAuth.registerAgentSession(userId, session.id, startTarget); + if (sessionPolicy?.gooseMode) { + const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', { + method: 'POST', + body: JSON.stringify({ + session_id: session.id, + goose_mode: sessionPolicy.gooseMode, + }), + }); + if (!modeRes.ok) { + const modeText = await modeRes.text().catch(() => ''); + throw new Error(modeText || `设置会话模式失败 (${modeRes.status})`); + } + } + return session; + } + async function resolveTarget(sessionId) { if (targets.length <= 1 || !sessionId) return primaryTarget; try { @@ -911,6 +956,7 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth, proxyFallback, proxySessionEvents, resolveTarget, + startSessionForUser, apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init), apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init), }; diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 73f126b..6acfbd0 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -35,6 +35,10 @@ const SESSION_WORKSPACE_TOOL_NAMES = new Set([ 'list_dir', ]); +function escapeRegExp(value) { + return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function parseXmlField(xml, field) { const cdataMatch = xml.match(new RegExp(`<${field}><\\/${field}>`)); if (cdataMatch) return cdataMatch[1]; @@ -310,7 +314,13 @@ function extractHtmlWriteTargets(messages = []) { const args = toolCall?.arguments ?? {}; const action = String(args.action ?? '').toLowerCase(); const writeLikeDeveloper = name === 'developer' && action === 'write'; - const writeLikeSandbox = (name === 'write_file' || name === 'edit_file') && typeof args.path === 'string'; + const normalizedName = String(name ?? '').trim(); + const writeLikeSandbox = + (normalizedName === 'write_file' || + normalizedName === 'edit_file' || + normalizedName.endsWith('__write_file') || + normalizedName.endsWith('__edit_file')) && + typeof args.path === 'string'; if (!writeLikeDeveloper && !writeLikeSandbox) continue; const candidate = String(args.path ?? '').trim(); if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate); @@ -343,6 +353,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('/'); @@ -351,7 +387,9 @@ function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) { function ensurePublicHtmlArtifact(htmlPath, workingDir) { const workspaceRoot = path.resolve(workingDir); - const source = path.resolve(htmlPath); + const source = path.isAbsolute(String(htmlPath ?? '')) + ? path.resolve(String(htmlPath)) + : path.resolve(workspaceRoot, String(htmlPath ?? '')); if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null; if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null; @@ -371,13 +409,47 @@ 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; +} + +function rewritePublishedHtmlLinks(text, artifacts = []) { + const value = String(text ?? ''); + if (!value || artifacts.length === 0) return value; + let next = value; + for (const artifact of artifacts) { + const canonicalUrl = String(artifact?.url ?? '').trim(); + const filename = String(artifact?.relativePath ?? '').replace(/\\/g, '/').split('/').pop(); + if (!canonicalUrl || !filename) continue; + const wrongPublicLinkPattern = new RegExp( + `https?:\\/\\/[^\\s)]+\\/(?:MindSpace\\/[^\\s/]+\\/)?public\\/${escapeRegExp(filename)}\\b`, + 'g', + ); + next = next.replace(wrongPublicLinkPattern, canonicalUrl); + next = next.replace(PUBLIC_HTML_LINK_PATTERN, (match) => { + if (match === canonicalUrl) return match; + const matchedFilename = String(match).split('/').pop(); + return matchedFilename === filename ? canonicalUrl : match; + }); + } + return next; +} + +async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) { + const artifacts = collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }); + const baseText = rewritePublishedHtmlLinks(String(reply?.text ?? '').trim(), artifacts).trim(); + for (const artifact of artifacts) { + const url = artifact.url; if (baseText.includes(url)) return baseText; return baseText ? `${baseText}\n\n查看页面:\n${url}` @@ -520,6 +592,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) { @@ -951,9 +1035,11 @@ export function createWechatMpService({ config, userAuth, apiFetch, + startAgentSession = null, sessionApiFetch = null, scheduleService = null, applySessionLlmProvider = null, + refreshSessionSnapshot = null, wechatFetch = undiciFetch, linkExists = defaultPublicHtmlLinkExists, logger = console, @@ -1089,9 +1175,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) { @@ -1216,26 +1314,28 @@ export function createWechatMpService({ if (!gate.ok) { throw new Error(gate.message || '当前用户无法使用聊天能力'); } - const started = await readJsonResponse( - await apiFetch('/agent/start', { - method: 'POST', - body: JSON.stringify({ - working_dir: workingDir, - enable_context_memory: sessionPolicy.enableContextMemory, - ...(sessionPolicy.extensionOverrides - ? { extension_overrides: sessionPolicy.extensionOverrides } - : {}), - }), - }), - ); + const started = startAgentSession + ? await startAgentSession({ userId, workingDir, sessionPolicy }) + : await readJsonResponse( + await apiFetch('/agent/start', { + method: 'POST', + body: JSON.stringify({ + working_dir: workingDir, + enable_context_memory: sessionPolicy.enableContextMemory, + ...(sessionPolicy.extensionOverrides + ? { extension_overrides: sessionPolicy.extensionOverrides } + : {}), + }), + }), + ); const sessionId = started?.id; if (!sessionId) { throw new Error('公众号专属 Agent 会话创建失败'); } - // `/agent/start` already persists the owning user and goosed node via the - // portal proxy. Re-registering here without the node can overwrite the - // correct mapping back to node 0 in multi-goosed production. + if (!startAgentSession && typeof userAuth.registerAgentSession === 'function') { + await userAuth.registerAgentSession(userId, sessionId); + } await reconcileAgentSession( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, @@ -1263,6 +1363,15 @@ export function createWechatMpService({ return sessionId; }; + const refreshWechatSessionSnapshot = async (sessionId, userId) => { + if (!sessionId || !userId || typeof refreshSessionSnapshot !== 'function') return; + try { + await refreshSessionSnapshot(sessionId, userId); + } catch (err) { + logger.warn?.('WeChat MP snapshot refresh failed:', err); + } + }; + const rememberWechatUserContext = async (sessionId, user) => { const addressName = resolveWechatAddressName(user); if (!addressName) return; @@ -1383,17 +1492,28 @@ 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 refreshWechatSessionSnapshot(sessionId, user.userId); + 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 +1536,28 @@ 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 refreshWechatSessionSnapshot(sessionId, user.userId); + 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..14d4fcf 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`; @@ -180,6 +253,89 @@ test('maybeAttachPublishedHtmlLink copies generated root html into public and re ); }); +test('maybeAttachPublishedHtmlLink recognizes sandbox-fs write_file html outputs', async () => { + const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-sandbox-fs-'); + const htmlPath = `${workspaceRoot}/public/codex-verify.html`; + fs.mkdirSync(`${workspaceRoot}/public`, { recursive: true }); + fs.writeFileSync(htmlPath, '