feat: sync portal runtime fixes for release
This commit is contained in:
@@ -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, {
|
||||
|
||||
-22
@@ -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/);
|
||||
});
|
||||
@@ -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')),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -19,10 +31,10 @@ cat > "$PLIST" <<EOF
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$ROOT/scripts/monitor-goosed-fds.mjs</string>
|
||||
<string>$MONITOR_SCRIPT</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$ROOT</string>
|
||||
<string>$MONITOR_ROOT</string>
|
||||
<key>StartInterval</key>
|
||||
<integer>60</integer>
|
||||
<key>RunAtLoad</key>
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
+1
-2
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -1982,7 +1982,7 @@ export function MindSpaceView({
|
||||
{!agentJobsLoading && agentJobsList.length === 0 && (
|
||||
<div className="mindspace-empty">
|
||||
<h2>还没有 AI 生成任务</h2>
|
||||
<p>在 OA 工作区选择资料并创建页面任务后,任务会出现在这里。</p>
|
||||
<p>在 OA 工作区选择资料并点击「AI 生成页面」后,任务会出现在这里。</p>
|
||||
</div>
|
||||
)}
|
||||
{!agentJobsLoading && agentJobsList.length > 0 && (
|
||||
|
||||
+5
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
|
||||
+187
-10
@@ -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}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${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;
|
||||
|
||||
@@ -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, '<!doctype html><title>Hello</title>');
|
||||
|
||||
const text = await maybeAttachPublishedHtmlLink(
|
||||
{
|
||||
text: '已完成',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'developer',
|
||||
arguments: {
|
||||
action: 'write',
|
||||
path: htmlPath,
|
||||
content: '<!doctype html><title>Hello</title>',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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, '<!doctype html><title>Hello</title>');
|
||||
}
|
||||
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":"<!doctype html><title>Hello</title>"}}}}]}}\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';
|
||||
|
||||
Reference in New Issue
Block a user