diff --git a/db.mjs b/db.mjs index 47cc91c..a371908 100644 --- a/db.mjs +++ b/db.mjs @@ -46,84 +46,6 @@ export function createDbPool() { }); } -export function splitSqlStatements(sql) { - const statements = []; - let current = ''; - let quote = null; - let lineComment = false; - let blockComment = false; - - for (let i = 0; i < sql.length; i += 1) { - const char = sql[i]; - const next = sql[i + 1]; - - if (lineComment) { - if (char === '\n') { - lineComment = false; - current += '\n'; - } - continue; - } - - if (blockComment) { - if (char === '*' && next === '/') { - blockComment = false; - i += 1; - } - continue; - } - - if (quote) { - current += char; - if (char === '\\' && (quote === '\'' || quote === '"') && next) { - current += next; - i += 1; - continue; - } - if (char === quote) { - if (next === quote && quote !== '`') { - current += next; - i += 1; - } else { - quote = null; - } - } - continue; - } - - if (char === '-' && next === '-') { - lineComment = true; - i += 1; - continue; - } - - if (char === '/' && next === '*') { - blockComment = true; - i += 1; - continue; - } - - if (char === '\'' || char === '"' || char === '`') { - quote = char; - current += char; - continue; - } - - if (char === ';') { - const trimmed = current.trim(); - if (trimmed) statements.push(trimmed); - current = ''; - continue; - } - - current += char; - } - - const trimmed = current.trim(); - if (trimmed) statements.push(trimmed); - return statements; -} - async function columnExists(pool, table, column) { const [rows] = await pool.query( `SELECT 1 FROM information_schema.COLUMNS @@ -203,13 +125,6 @@ export async function migrateSchema(pool) { } } - if (!(await columnExists(pool, 'h5_session_snapshots', 'display_title'))) { - await pool.query( - `ALTER TABLE h5_session_snapshots - ADD COLUMN display_title VARCHAR(512) NOT NULL DEFAULT '' AFTER name`, - ); - } - await pool.query(`UPDATE h5_users SET slug = username WHERE slug IS NULL OR slug = ''`); if (!(await indexExists(pool, 'h5_users', 'uq_h5_users_slug'))) { await pool.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_slug (slug)`); @@ -318,6 +233,7 @@ export async function migrateSchema(pool) { `); const publishColumns = [ + ['user_confirmed_at', 'BIGINT NULL AFTER expires_at'], ['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'], ['plaza_like_count', 'BIGINT NOT NULL DEFAULT 0'], ]; @@ -327,6 +243,13 @@ export async function migrateSchema(pool) { } } + if (!(await indexExists(pool, 'h5_publish_records', 'idx_h5_publish_auto_private'))) { + await pool.query( + `ALTER TABLE h5_publish_records + ADD KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at)`, + ); + } + const plazaUserColumns = [ ['plaza_post_count', 'INT UNSIGNED NOT NULL DEFAULT 0'], ['plaza_follower_count', 'INT UNSIGNED NOT NULL DEFAULT 0'], @@ -367,6 +290,12 @@ export async function migrateSchema(pool) { ); } + if (!(await columnExists(pool, 'h5_session_snapshots', 'display_title'))) { + await pool.query( + `ALTER TABLE h5_session_snapshots ADD COLUMN display_title VARCHAR(512) NOT NULL DEFAULT '' AFTER name`, + ); + } + const oauthStateColumns = [ ['intent', "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"], ['bind_user_id', 'CHAR(36) NULL AFTER intent'], @@ -602,6 +531,7 @@ export async function migrateSchema(pool) { agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY, user_id CHAR(36) NOT NULL, name VARCHAR(512) NOT NULL DEFAULT '', + display_title VARCHAR(512) NOT NULL DEFAULT '', working_dir VARCHAR(1024) NOT NULL DEFAULT '', created_at_str VARCHAR(64) NOT NULL DEFAULT '', updated_at_str VARCHAR(64) NOT NULL DEFAULT '', @@ -673,9 +603,16 @@ export async function migrateSchema(pool) { export async function initSchema(pool) { const schemaPath = path.join(__dirname, 'schema.sql'); - const sql = fs.readFileSync(schemaPath, 'utf8'); - for (const statement of splitSqlStatements(sql)) { - await pool.query(statement); + const sql = fs + .readFileSync(schemaPath, 'utf8') + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n'); + for (const statement of sql.split(';')) { + const trimmed = statement.trim(); + if (trimmed) { + await pool.query(trimmed); + } } await migrateSchema(pool); await ensureDefaultSpaces(pool, { diff --git a/db.test.mjs b/db.test.mjs deleted file mode 100644 index 93d9f16..0000000 --- a/db.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { splitSqlStatements } from './db.mjs'; - -test('splitSqlStatements ignores semicolons in comments and strings', () => { - const statements = splitSqlStatements(` - -- comment with a semicolon; should not split - CREATE TABLE one ( - label VARCHAR(64) DEFAULT 'semi;colon' - ); - /* block; comment */ - CREATE TABLE two ( - id BIGINT PRIMARY KEY, - name VARCHAR(64) DEFAULT "two;name" - ); - `); - - assert.equal(statements.length, 2); - assert.match(statements[0], /^CREATE TABLE one/); - assert.match(statements[1], /^CREATE TABLE two/); -}); diff --git a/mindspace-assets.test.mjs b/mindspace-assets.test.mjs index 4fdb658..a63efc3 100644 --- a/mindspace-assets.test.mjs +++ b/mindspace-assets.test.mjs @@ -265,7 +265,7 @@ test('completeUpload publishes public images to the public temp image library', const asset = await service.completeUpload('user-1', upload.id); assert.equal(asset.status, 'ready'); - assert.match(asset.publicUrl, /^https:\/\/g2\.tkmind\.cn\/MindSpace\/user-1\/public\/\.tmp-images\/id-2\.png$/); + assert.match(asset.publicUrl, /^https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/\.tmp-images\/id-2\.png$/); assert.equal(state.versions[0].storage_key, 'users/user-1/public/.tmp-images/id-2.png'); await assert.doesNotReject(() => fs.stat(path.join(storageRoot, 'users/user-1/public/.tmp-images/id-2.png')), diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index f8b2095..583fd4a 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -40,6 +40,9 @@ function resolveSandboxed(p) { if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) { throw Object.assign(new Error(`路径越界:${p} 不在当前工作区内`), { code: 'EACCES' }); } + if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path.sep)) { + throw Object.assign(new Error('禁止直接访问私有数据目录,请使用 private_data_* 工具'), { code: 'EACCES' }); + } return resolved; } diff --git a/schema.sql b/schema.sql index b30407f..bd1976d 100644 --- a/schema.sql +++ b/schema.sql @@ -233,7 +233,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records ( updated_at BIGINT NOT NULL, KEY idx_h5_publish_route (url_slug, status, published_at), KEY idx_h5_publish_page (user_id, page_id, published_at), - KEY idx_h5_publish_auto_private (expires_at, user_confirmed_at), + KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at), UNIQUE KEY uq_h5_publish_token_hash (token_hash), CONSTRAINT fk_h5_publish_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, CONSTRAINT fk_h5_publish_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE, @@ -1014,7 +1014,7 @@ CREATE TABLE IF NOT EXISTS h5_blocked_words ( -- Shared experience store (etat C): all goosed Worker instances read/write the -- same learned experience so capability is not siloed per instance. Built on --- MySQL first with a keyword + recency retrieval. `embedding` is reserved for a +-- MySQL first with a keyword + recency retrieval; `embedding` is reserved for a -- later move to PostgreSQL + pgvector without changing the calling contract. CREATE TABLE IF NOT EXISTS h5_experience ( id CHAR(36) PRIMARY KEY, diff --git a/scripts/install-goosed-monitor.sh b/scripts/install-goosed-monitor.sh index a529796..931444e 100755 --- a/scripts/install-goosed-monitor.sh +++ b/scripts/install-goosed-monitor.sh @@ -3,12 +3,24 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}" +DEFAULT_MONITOR_SCRIPT="$ROOT/scripts/monitor-goosed-fds.mjs" +if [[ -x "/Users/john/Project/tkmind_go/scripts/monitor-goosed-fds.mjs" ]]; then + DEFAULT_MONITOR_SCRIPT="/Users/john/Project/tkmind_go/scripts/monitor-goosed-fds.mjs" +fi +MONITOR_SCRIPT="${GOOSED_MONITOR_SCRIPT:-$DEFAULT_MONITOR_SCRIPT}" +MONITOR_ROOT="$(cd "$(dirname "$MONITOR_SCRIPT")/.." && pwd)" PLIST="$HOME/Library/LaunchAgents/cn.tkmind.goosed-monitor.plist" LOG="$HOME/Library/Logs/goosed-monitor.log" GUI="gui/$(id -u)" mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" +if [[ ! -x "$MONITOR_SCRIPT" ]]; then + echo "monitor script not found or not executable: $MONITOR_SCRIPT" >&2 + echo "Set GOOSED_MONITOR_SCRIPT to a stable goosed ops script path." >&2 + exit 1 +fi + cat > "$PLIST" < @@ -19,10 +31,10 @@ cat > "$PLIST" <ProgramArguments $NODE_BIN - $ROOT/scripts/monitor-goosed-fds.mjs + $MONITOR_SCRIPT WorkingDirectory - $ROOT + $MONITOR_ROOT StartInterval 60 RunAtLoad @@ -55,4 +67,5 @@ launchctl enable "$GUI/cn.tkmind.goosed-monitor" launchctl kickstart -k "$GUI/cn.tkmind.goosed-monitor" echo "installed $PLIST" +echo "script: $MONITOR_SCRIPT" echo "log: $LOG" diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index fcf4bc2..647ee4e 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -80,7 +80,39 @@ need_cmd scp need_cmd tar need_cmd shasum +check_release_scope() { + local bypass="${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}" + if [[ "${bypass}" == "1" ]]; then + say "跳过发版范围检查(ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1)" + return 0 + fi + git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + + local path + local blocked=() + while IFS= read -r path; do + [[ -n "${path}" ]] || continue + case "${path}" in + docker/*|scripts/start-goosed-local.sh|scripts/verify-goosed-docker.sh|docs/goosed-*.md|server.mjs.bak*|*.bak) + blocked+=("${path}") + ;; + docs/local-dev.md|README.md) + blocked+=("${path}") + ;; + esac + done < <(git -C "${ROOT}" status --short --untracked-files=all | sed -E 's/^.. //') + + if [[ "${#blocked[@]}" -gt 0 ]]; then + echo "检测到默认不应并入 103 Portal 发版的本地改动:" >&2 + printf ' - %s\n' "${blocked[@]}" >&2 + echo "请先清理这些改动,或确认本次需求明确包含这些模块后再重试。" >&2 + echo "如确需绕过,可临时设置 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1。" >&2 + exit 1 + fi +} + say "本地预检查" +check_release_scope ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/.env'" >/dev/null @@ -121,6 +153,87 @@ verify_runtime_artifact() { verify_runtime_artifact +verify_remote_goosed_dependency() { + say "检查 103 goosed 依赖" + ssh -o BatchMode=yes "${HOST}" 'bash -s' <<'REMOTE' +set -euo pipefail + +missing=0 + +docker_bin="/opt/homebrew/bin/docker" +if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/null | grep -q '^goosed-prod-1$'; then + for index in 1 2 3 4; do + container="goosed-prod-${index}" + host_port=$((18005 + index)) + if ! "${docker_bin}" ps --format '{{.Names}} {{.Ports}} {{.Status}}' | grep -q "^${container} .*0.0.0.0:${host_port}->18006/tcp.*healthy"; then + echo "goosed dependency check failed: ${container} is not healthy on host port ${host_port}" >&2 + missing=1 + fi + if ! "${docker_bin}" exec "${container}" sh -lc 'test -x /usr/local/bin/node && test -f /opt/portal/mindspace-sandbox-mcp.mjs' >/dev/null 2>&1; then + echo "goosed dependency check failed: ${container} missing /usr/local/bin/node or /opt/portal/mindspace-sandbox-mcp.mjs" >&2 + missing=1 + fi + done + if [[ -f /Users/john/Project/Memind/.env ]]; then + portal_mcp_node="$(grep -E '^GOOSED_MCP_NODE_PATH=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)" + portal_mcp_server="$(grep -E '^GOOSED_MCP_SERVER_PATH=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)" + if [[ -z "${portal_mcp_node}" || -z "${portal_mcp_server}" ]]; then + echo "goosed dependency check failed: Portal .env must set GOOSED_MCP_NODE_PATH and GOOSED_MCP_SERVER_PATH for Docker goosed" >&2 + missing=1 + else + for index in 1 2 3 4; do + container="goosed-prod-${index}" + if ! "${docker_bin}" exec "${container}" sh -lc "test -x '${portal_mcp_node}' && test -f '${portal_mcp_server}'" >/dev/null 2>&1; then + echo "goosed dependency check failed: ${container} cannot resolve Portal .env MCP paths '${portal_mcp_node}' and '${portal_mcp_server}'" >&2 + missing=1 + fi + done + fi + fi +else + for port in 18006 18007; do + line="$(lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1, $2, $9}')" + if [[ -z "${line}" ]]; then + echo "goosed dependency check failed: port ${port} has no listener" >&2 + missing=1 + continue + fi + command_name="${line%% *}" + case "${command_name}" in + goosed) ;; + *) + echo "goosed dependency check failed: port ${port} is listened by ${line}, not goosed" >&2 + missing=1 + ;; + esac + done + + if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18006'; then + echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18006 is not loaded" >&2 + missing=1 + fi + if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18007'; then + echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18007 is not loaded" >&2 + missing=1 + fi + if [[ ! -x /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh ]]; then + echo "goosed dependency check failed: missing /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh" >&2 + missing=1 + fi + if [[ ! -x /Users/john/Project/tkmind_go/target/release/goosed && ! -x /Users/john/Project/tkmind_go/target/debug/goosed ]]; then + echo "goosed dependency check failed: missing goosed binary under /Users/john/Project/tkmind_go/target" >&2 + missing=1 + fi +fi + +exit "${missing}" +REMOTE +} + +if [[ "${DRY_RUN}" -ne 1 ]]; then + verify_remote_goosed_dependency +fi + if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then say "发布确认" echo "目标主机: ${HOST}" diff --git a/scripts/run-memind-portal-prod.sh b/scripts/run-memind-portal-prod.sh index 146bd92..1f14012 100755 --- a/scripts/run-memind-portal-prod.sh +++ b/scripts/run-memind-portal-prod.sh @@ -15,6 +15,7 @@ fi export NODE_ENV=production export H5_PORT="${H5_PORT:-8081}" export H5_PUBLIC_BASE_URL="https://m.tkmind.cn" +export TKMIND_API_TARGETS="${TKMIND_API_TARGETS_OVERRIDE:-${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}}" export TKMIND_API_TARGET="${TKMIND_API_TARGET_OVERRIDE:-https://127.0.0.1:18006}" export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1_OVERRIDE:-https://127.0.0.1:18007}" diff --git a/server.mjs b/server.mjs index 2181f1e..e468f4e 100644 --- a/server.mjs +++ b/server.mjs @@ -2508,8 +2508,6 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { thumbnailReady = false; } } - const sessionId = String(req.body?.session_id ?? req.body?.sessionId ?? '').trim(); - const messageId = String(req.body?.message_id ?? req.body?.messageId ?? '').trim(); const existingPage = await mindSpacePages.findPageBySourceMessage( req.currentUser.id, sessionId, @@ -2546,6 +2544,7 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); + console.log('[quick-share] bundle resolved, hasHtml:', Boolean(bundle.resolvedHtml)); if (!bundle.resolvedHtml) { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); } diff --git a/src/components/MindSpaceView.tsx b/src/components/MindSpaceView.tsx index ac011e4..c5f7115 100644 --- a/src/components/MindSpaceView.tsx +++ b/src/components/MindSpaceView.tsx @@ -1982,7 +1982,7 @@ export function MindSpaceView({ {!agentJobsLoading && agentJobsList.length === 0 && (

还没有 AI 生成任务

-

在 OA 工作区选择资料并创建页面任务后,任务会出现在这里。

+

在 OA 工作区选择资料并点击「AI 生成页面」后,任务会出现在这里。

)} {!agentJobsLoading && agentJobsList.length > 0 && ( diff --git a/user-auth.mjs b/user-auth.mjs index b46d3d0..5f5cac7 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -1662,6 +1662,11 @@ export function createUserAuth(pool, options = {}) { try { const layout = await publishLayoutFor(user, { migrateLegacy: false }); sandboxMcp = { + // When goosed runs in a container its filesystem is split from the portal's, + // so the host paths the portal would otherwise send (node binary, MCP script) + // are not resolvable. These env overrides let the portal send container-canonical + // paths instead. Unset (e.g. local dev, co-located native goosed) => fall back to + // the portal's own paths, so behavior is unchanged. serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH), sandboxRoot: layout.publishDir, userId: user.id, @@ -2709,7 +2714,6 @@ export function createUserAuth(pool, options = {}) { policyCatalog: POLICY_CATALOG, skillCatalog, publicUser, - getUserById, }; } diff --git a/user-publish.test.mjs b/user-publish.test.mjs index 74b6117..8404a17 100644 --- a/user-publish.test.mjs +++ b/user-publish.test.mjs @@ -57,7 +57,7 @@ test('publish dir and public url use stable user id', () => { const skillPath = path.join(layout.publishDir, '.agents', 'skills', 'static-page-publish', 'SKILL.md'); assert.ok(fs.existsSync(skillPath)); const skillText = fs.readFileSync(skillPath, 'utf8'); - assert.match(skillText, new RegExp(`g2\\.tkmind\\.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/${PUBLIC_ZONE_DIR}/`)); + assert.match(skillText, new RegExp(`m\\.tkmind\\.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/${PUBLIC_ZONE_DIR}/`)); assert.match(skillText, /public\/report\.html/); assert.match(skillText, /\[.*\]\(.*\)/); assert.match(skillText, /mindspace-cover/); diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 9771a5c..73f126b 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fetch as undiciFetch } from 'undici'; +import { developerToolsFromPolicy } from './capabilities.mjs'; import { mergeMessageContent } from './message-stream.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs'; @@ -23,6 +24,16 @@ const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agen const DEFAULT_PROGRESS_DELAY_MS = 8000; const PUBLIC_HTML_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi; +const SESSION_WORKSPACE_TOOL_NAMES = new Set([ + 'write', + 'edit', + 'tree', + 'read_file', + 'write_file', + 'edit_file', + 'create_dir', + 'list_dir', +]); function parseXmlField(xml, field) { const cdataMatch = xml.match(new RegExp(`<${field}><\\/${field}>`)); @@ -182,6 +193,7 @@ async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metad const decoder = new TextDecoder(); let buffer = ''; let messages = []; + let hasScopedAssistantUpdate = false; while (true) { const { value, done } = await reader.read(); @@ -209,16 +221,26 @@ async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metad if (hasActionRequired) { throw new Error('当前回复需要人工确认,公众号通道暂不支持'); } + if (event.message.role === 'assistant') hasScopedAssistantUpdate = true; messages = pushMessage(messages, event.message); } else if (event.type === 'UpdateConversation') { - messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible); + // Ignore unscoped snapshots until this request has yielded an assistant update. + // Otherwise a stale session snapshot can overwrite the current reply with a + // previous page/link from the same WeChat-dedicated session. + if (hasScopedAssistantUpdate) { + messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible); + } } else if (event.type === 'Error') { throw new Error(event.error || '任务执行失败'); } else if (event.type === 'Finish') { const assistant = [...messages].reverse().find((item) => item.role === 'assistant'); + if (!hasScopedAssistantUpdate || !assistant) { + throw new Error('本轮未收到可发送的新回复,请稍后重试'); + } return { text: messageVisibleText(assistant), tokenState: event.token_state ?? null, + messages, }; } } @@ -253,6 +275,117 @@ function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) { export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES }; +function flattenSessionTools(extensions = []) { + return new Set( + extensions.flatMap((extension) => + Array.isArray(extension?.available_tools) ? extension.available_tools : [], + ), + ); +} + +function needsWorkspaceTools(sessionPolicy) { + return developerToolsFromPolicy(sessionPolicy).some((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool)); +} + +async function readSessionExtensions(fetchForSession, sessionId) { + const payload = await readJsonResponse(await fetchForSession(sessionId, `/sessions/${sessionId}/extensions`)); + return payload?.extensions ?? []; +} + +async function sessionHasRequiredTools(fetchForSession, sessionId, sessionPolicy) { + if (!needsWorkspaceTools(sessionPolicy)) return true; + const desired = developerToolsFromPolicy(sessionPolicy).filter((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool)); + if (desired.length === 0) return true; + const available = flattenSessionTools(await readSessionExtensions(fetchForSession, sessionId)); + return desired.some((tool) => available.has(tool)); +} + +function extractHtmlWriteTargets(messages = []) { + const targets = new Set(); + for (const message of messages) { + for (const item of message?.content ?? []) { + if (item?.type !== 'toolRequest') continue; + const toolCall = item.toolCall?.value; + const name = toolCall?.name; + 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'; + if (!writeLikeDeveloper && !writeLikeSandbox) continue; + const candidate = String(args.path ?? '').trim(); + if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate); + } + } + return [...targets]; +} + +function looksLikeHtmlGenerationIntent(text) { + const normalized = String(text ?? '').trim().toLowerCase(); + if (!normalized) return false; + return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized); +} + +function isBareCompletionText(text) { + const normalized = String(text ?? '').trim(); + return /^(?:已完成|完成了|完成)$/u.test(normalized); +} + +function hasAnyToolRequest(messages = []) { + return messages.some((message) => + message?.content?.some((item) => item?.type === 'toolRequest'), + ); +} + +function isSuspiciousBareCompletionReply(reply, intent) { + if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; + if (!isBareCompletionText(reply?.text)) return false; + if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false; + return !hasAnyToolRequest(reply?.messages ?? []); +} + +function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) { + const owner = path.basename(path.resolve(workingDir)); + const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/'); + return `${String(publicBaseUrl ?? '').replace(/\/+$/, '')}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(owner)}/${normalized}`; +} + +function ensurePublicHtmlArtifact(htmlPath, workingDir) { + const workspaceRoot = path.resolve(workingDir); + const source = path.resolve(htmlPath); + if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null; + if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null; + + const publicRoot = path.join(workspaceRoot, 'public'); + let publishedPath = source; + if (source !== publicRoot && !source.startsWith(`${publicRoot}${path.sep}`)) { + fs.mkdirSync(publicRoot, { recursive: true }); + publishedPath = path.join(publicRoot, path.basename(source)); + if (publishedPath !== source) fs.copyFileSync(source, publishedPath); + } + + const relativePath = path.relative(workspaceRoot, publishedPath); + if (!relativePath || relativePath.startsWith('..')) return null; + return { + localPath: publishedPath, + relativePath, + }; +} + +async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) { + const baseText = String(reply?.text ?? '').trim(); + 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); + if (baseText.includes(url)) return baseText; + return baseText + ? `${baseText}\n\n查看页面:\n${url}` + : `查看页面:\n${url}`; + } + return baseText; +} + function stripInternalWechatUsername(text) { return String(text ?? '').replace(/^\s*wx_[a-z0-9_]{4,64}\s*[,,、::]\s*/i, ''); } @@ -387,6 +520,8 @@ export async function guardMissingPublicHtmlLinks( return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value); } +export { maybeAttachPublishedHtmlLink }; + function isQuestionStatusProbe(text) { return /^[??]+$/.test(String(text ?? '').trim()); } @@ -1040,19 +1175,47 @@ export function createWechatMpService({ if (forceNew) { await userAuth.clearWechatAgentRoute(config.appId, openid); } + const workingDir = await userAuth.resolveWorkingDir(userId); + const sessionPolicy = await userAuth.getAgentSessionPolicy(userId); + const publishLayout = await userAuth.getUserPublishLayout(userId); + const addressName = resolveWechatAddressName(userContext); const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid); if (existingRoute?.agentSessionId) { - return existingRoute.agentSessionId; + await reconcileAgentSession( + (pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init), + existingRoute.agentSessionId, + { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + userContext: publishLayout + ? { + userId, + displayName: addressName || publishLayout.displayName, + username: addressName || null, + slug: null, + } + : null, + tolerateInvalidWorkingDir: true, + }, + ); + const routeHasTools = await sessionHasRequiredTools( + fetchForSession, + existingRoute.agentSessionId, + sessionPolicy, + ).catch(() => false); + if (routeHasTools) { + return existingRoute.agentSessionId; + } + await userAuth.clearWechatAgentRoute(config.appId, openid); + } else if (existingRoute?.status === 'disabled') { + await userAuth.clearWechatAgentRoute(config.appId, openid); } const gate = await userAuth.canUseChat(userId); if (!gate.ok) { throw new Error(gate.message || '当前用户无法使用聊天能力'); } - - const workingDir = await userAuth.resolveWorkingDir(userId); - const sessionPolicy = await userAuth.getAgentSessionPolicy(userId); - const publishLayout = await userAuth.getUserPublishLayout(userId); const started = await readJsonResponse( await apiFetch('/agent/start', { method: 'POST', @@ -1073,7 +1236,6 @@ export function createWechatMpService({ // `/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. - const addressName = resolveWechatAddressName(userContext); await reconcileAgentSession( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, @@ -1175,6 +1337,7 @@ export function createWechatMpService({ userContext: user, forceNew, }); + const publishLayout = await userAuth.getUserPublishLayout(user.userId); await ensureSessionProvider(sessionId); await rememberWechatUserContext(sessionId, user); let finished = false; @@ -1220,15 +1383,22 @@ export function createWechatMpService({ buildWechatAgentPrompt(intent), buildIntentMetadata(intent), ); + if (isSuspiciousBareCompletionReply(reply, intent)) { + throw new Error('stale_session_poisoned_completion'); + } if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId); } - await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user); + const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { + workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)), + publicBaseUrl: config.publicBaseUrl, + }); + await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user); return { sessionId }; } catch (err) { const message = err instanceof Error ? err.message : String(err); const mayBeStaleSession = - /403|404|not found|无权访问|session/i.test(message) && sessionId; + /403|404|not found|无权访问|session|stale_session_poisoned_completion/i.test(message) && sessionId; if (mayBeStaleSession) { sessionId = await ensureWechatAgentSession({ userId: user.userId, @@ -1246,10 +1416,17 @@ export function createWechatMpService({ buildWechatAgentPrompt(intent), buildIntentMetadata(intent), ); + if (isSuspiciousBareCompletionReply(reply, intent)) { + throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试'); + } if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId); } - await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user); + const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { + workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)), + publicBaseUrl: config.publicBaseUrl, + }); + await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user); return { sessionId }; } throw err; diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 7617aaf..7f6d036 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -7,6 +7,7 @@ import { createWechatMpService, guardMissingPublicHtmlLinks, loadWechatMpConfig, + maybeAttachPublishedHtmlLink, splitWechatText, verifyWechatMpSignature, verifyWechatMpUrlChallenge, @@ -136,6 +137,48 @@ test('guardMissingPublicHtmlLinks blocks missing MindSpace public html links', a assert.match(guarded, /missing\.html/); }); +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`; + fs.writeFileSync(htmlPath, 'Hello'); + + const text = await maybeAttachPublishedHtmlLink( + { + text: '已完成', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'developer', + arguments: { + action: 'write', + path: htmlPath, + content: 'Hello', + }, + }, + }, + }, + ], + }, + ], + }, + { + workingDir: workspaceRoot, + publicBaseUrl: 'https://m.tkmind.cn', + }, + ); + + assert.equal(fs.existsSync(`${workspaceRoot}/public/hello.html`), true); + assert.match( + text, + /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello\.html/, + ); +}); + test('wechat mp service splits long agent replies into multiple customer messages', async () => { const token = 'token'; const timestamp = '1710000000'; @@ -367,6 +410,70 @@ test('wechat mp service strips markdown emphasis around outbound links', async ( assert.match(payload.text.content, /https:\/\/example\.com\/running-route\.html/); }); +test('wechat mp service does not send stale page links from unscoped conversation snapshots', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const wechatCalls = []; + const service = createBoundWechatService({ + sessionApiFetch: async (sessionId, pathname) => { + assert.equal(sessionId, 'session-1'); + if (pathname === '/sessions/session-1/events') { + return new Response( + [ + 'data: {"type":"UpdateConversation","conversation":[{"id":"user-old","role":"user","metadata":{"userVisible":true},"content":[{"type":"text","text":"帮我做餐厅推荐"}]},{"id":"assistant-old","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"页面已生成!餐厅推荐\\nhttps://g2.tkmind.cn/MindSpace/old/public/restaurant.html"}]}]}\n\n', + 'data: {"type":"Finish","request_id":"req-stale","token_state":{"inputTokens":1,"outputTokens":2}}\n\n', + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname === '/sessions/session-1/reply') { + 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' } }); + } + throw new Error(`unexpected api path: ${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}`); + }, + }); + + const originalRandomUuid = crypto.randomUUID; + crypto.randomUUID = () => 'req-stale'; + try { + const result = await service.handleInboundMessage(inboundXml({ content: '帮我做上马计划' }), { + timestamp, + nonce, + signature: signatureFor(token, timestamp, nonce), + }); + assert.equal(result.status, 200); + await result.task; + } finally { + crypto.randomUUID = originalRandomUuid; + } + + const sendCalls = wechatCalls.filter(([url]) => String(url).includes('/cgi-bin/message/custom/send')); + assert.equal(sendCalls.length, 1); + const payload = JSON.parse(sendCalls[0][2]); + assert.doesNotMatch(payload.text.content, /restaurant\.html/); + assert.match(payload.text.content, /本轮未收到可发送的新回复/); +}); + test('loadWechatMpConfig requires full config and enable flag', () => { const config = loadWechatMpConfig({ H5_WECHAT_MP_ENABLED: '1', @@ -873,6 +980,339 @@ test('wechat mp service injects schedule skill instructions for reminder-like re } }); +test('wechat mp service reconciles existing dedicated session before reply', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const calls = []; + let extensionsReadCount = 0; + const workingDir = '/Users/john/Project/Memind/MindSpace/user-1'; + const extensionConfig = { + type: 'stdio', + name: 'sandbox-fs', + cmd: '/usr/local/bin/node', + args: ['/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs', workingDir], + envs: { SANDBOX_ROOT: workingDir }, + available_tools: ['list_dir'], + }; + const service = createWechatMpService({ + config: { + enabled: true, + appId: 'wx123', + appSecret: 'secret', + token, + publicBaseUrl: 'https://example.com', + bindPath: '/auth/wechat/authorize?intent=login', + ackText: 'ack', + unsupportedText: 'unsupported', + unboundTextPrefix: '请先绑定', + }, + userAuth: { + async findWechatUserByOpenid() { + return { userId: 'user-1', status: 'active', nickname: '毕升' }; + }, + async getWechatAgentRoute() { + return { agentSessionId: 'session-1' }; + }, + async clearWechatAgentRoute() {}, + async canUseChat() { + return { ok: true }; + }, + async resolveWorkingDir() { + return workingDir; + }, + async getAgentSessionPolicy() { + return { + enableContextMemory: false, + extensionOverrides: [extensionConfig], + unrestricted: false, + gooseMode: 'auto', + }; + }, + async getUserPublishLayout() { + return { displayName: 'John', username: 'john', slug: 'john', constraints: 'base' }; + }, + async registerAgentSession() {}, + async upsertWechatAgentRoute() {}, + async billSessionUsage() {}, + }, + apiFetch: async (pathname, init = {}) => { + calls.push(['fallback', pathname, init.method ?? 'GET']); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }, + sessionApiFetch: async (sessionId, pathname, init = {}) => { + assert.equal(sessionId, 'session-1'); + calls.push([pathname, init.method ?? 'GET']); + if (pathname === '/sessions/session-1') { + return new Response(JSON.stringify({ working_dir: '/root/tkmind_go/ui/h5/MindSpace/user-1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (pathname === '/agent/update_working_dir') { + const body = JSON.parse(init.body); + assert.equal(body.session_id, 'session-1'); + assert.equal(body.working_dir, workingDir); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/update_session') { + const body = JSON.parse(init.body); + assert.equal(body.session_id, 'session-1'); + assert.equal(body.goose_mode, 'auto'); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/sessions/session-1/extensions') { + extensionsReadCount += 1; + const extensions = + extensionsReadCount > 1 + ? [{ name: 'sandbox-fs', available_tools: ['list_dir'] }] + : []; + return new Response(JSON.stringify({ extensions }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (pathname === '/agent/add_extension') { + const body = JSON.parse(init.body); + assert.equal(body.session_id, 'session-1'); + assert.equal(body.config.name, 'sandbox-fs'); + assert.equal(body.config.args[1], workingDir); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/restart') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/harness_remember') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/harness_bootstrap') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + 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":"收到"}]}}\n\n', + 'data: {"type":"Finish","request_id":"req-1","token_state":{"inputTokens":1,"outputTokens":1}}\n\n', + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname === '/sessions/session-1/reply') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + throw new Error(`unexpected api path: ${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')) { + 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 = () => 'req-1'; + 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(calls.some(([pathname]) => pathname === '/agent/update_working_dir'), true); + assert.equal(calls.some(([pathname]) => pathname === '/agent/add_extension'), true); + assert.equal(calls.some(([pathname]) => pathname === '/sessions/session-1/reply'), true); + } finally { + crypto.randomUUID = originalRandomUuid; + } +}); + +test('wechat mp service recreates poisoned dedicated session after bare completion on html generation', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const wechatCalls = []; + const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-poisoned-session-'); + const htmlPath = `${workspaceRoot}/hello.html`; + let routeCleared = false; + let started = false; + + 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({ appId, openid, userId, agentSessionId }) { + assert.equal(appId, 'wx123'); + assert.equal(openid, 'openid-1'); + assert.equal(userId, 'user-1'); + 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, init = {}) => { + 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.writeFileSync(htmlPath, 'Hello'); + } + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === `/sessions/${sessionId}/events`) { + if (sessionId === 'session-1') { + return new Response( + [ + 'data: {"type":"Message","request_id":"req-poisoned","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已完成"}]}}\n\n', + 'data: {"type":"Finish","request_id":"req-poisoned","token_state":{"inputTokens":1,"outputTokens":1}}\n\n', + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + return new Response( + [ + `data: {"type":"Message","request_id":"req-poisoned-retry","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"developer","arguments":{"action":"write","path":"${htmlPath.replace(/\\/g, '\\\\')}","content":"Hello"}}}}]}}\n\n`, + 'data: {"type":"Message","request_id":"req-poisoned-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已完成"}]}}\n\n', + 'data: {"type":"Finish","request_id":"req-poisoned-retry","token_state":{"inputTokens":2,"outputTokens":2}}\n\n', + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + 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}`); + }, + }); + + const originalRandomUuid = crypto.randomUUID; + crypto.randomUUID = (() => { + const ids = ['req-poisoned', 'req-poisoned-retry']; + return () => ids.shift() ?? 'req-poisoned-retry'; + })(); + try { + const result = await service.handleInboundMessage( + inboundXml({ content: '帮我生成一个简单的 hello 页面吧' }), + { + timestamp, + nonce, + signature: signatureFor(token, timestamp, nonce), + }, + ); + assert.equal(result.status, 200); + await result.task; + } finally { + crypto.randomUUID = originalRandomUuid; + } + + assert.equal(routeCleared, true); + assert.equal(started, true); + const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); + const payload = JSON.parse(sendCall[2]); + assert.match(payload.text.content, /已完成/); + assert.match(payload.text.content, /页面生成未完成|hello\.html/); +}); + test('wechat mp service blocks schedule confirmation when no schedule item was written', async () => { const token = 'token'; const timestamp = '1710000000';