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) {
|
async function columnExists(pool, table, column) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT 1 FROM information_schema.COLUMNS
|
`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 = ''`);
|
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'))) {
|
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)`);
|
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 = [
|
const publishColumns = [
|
||||||
|
['user_confirmed_at', 'BIGINT NULL AFTER expires_at'],
|
||||||
['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'],
|
['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'],
|
||||||
['plaza_like_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 = [
|
const plazaUserColumns = [
|
||||||
['plaza_post_count', 'INT UNSIGNED NOT NULL DEFAULT 0'],
|
['plaza_post_count', 'INT UNSIGNED NOT NULL DEFAULT 0'],
|
||||||
['plaza_follower_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 = [
|
const oauthStateColumns = [
|
||||||
['intent', "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"],
|
['intent', "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"],
|
||||||
['bind_user_id', 'CHAR(36) NULL AFTER intent'],
|
['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,
|
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||||
user_id CHAR(36) NOT NULL,
|
user_id CHAR(36) NOT NULL,
|
||||||
name VARCHAR(512) NOT NULL DEFAULT '',
|
name VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
display_title VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
working_dir VARCHAR(1024) NOT NULL DEFAULT '',
|
working_dir VARCHAR(1024) NOT NULL DEFAULT '',
|
||||||
created_at_str VARCHAR(64) NOT NULL DEFAULT '',
|
created_at_str VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
updated_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) {
|
export async function initSchema(pool) {
|
||||||
const schemaPath = path.join(__dirname, 'schema.sql');
|
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||||
const sql = fs.readFileSync(schemaPath, 'utf8');
|
const sql = fs
|
||||||
for (const statement of splitSqlStatements(sql)) {
|
.readFileSync(schemaPath, 'utf8')
|
||||||
await pool.query(statement);
|
.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 migrateSchema(pool);
|
||||||
await ensureDefaultSpaces(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);
|
const asset = await service.completeUpload('user-1', upload.id);
|
||||||
|
|
||||||
assert.equal(asset.status, 'ready');
|
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');
|
assert.equal(state.versions[0].storage_key, 'users/user-1/public/.tmp-images/id-2.png');
|
||||||
await assert.doesNotReject(() =>
|
await assert.doesNotReject(() =>
|
||||||
fs.stat(path.join(storageRoot, 'users/user-1/public/.tmp-images/id-2.png')),
|
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)) {
|
if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) {
|
||||||
throw Object.assign(new Error(`路径越界:${p} 不在当前工作区内`), { code: 'EACCES' });
|
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;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -233,7 +233,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records (
|
|||||||
updated_at BIGINT NOT NULL,
|
updated_at BIGINT NOT NULL,
|
||||||
KEY idx_h5_publish_route (url_slug, status, published_at),
|
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_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),
|
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_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,
|
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
|
-- Shared experience store (etat C): all goosed Worker instances read/write the
|
||||||
-- same learned experience so capability is not siloed per instance. Built on
|
-- 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.
|
-- later move to PostgreSQL + pgvector without changing the calling contract.
|
||||||
CREATE TABLE IF NOT EXISTS h5_experience (
|
CREATE TABLE IF NOT EXISTS h5_experience (
|
||||||
id CHAR(36) PRIMARY KEY,
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
|||||||
@@ -3,12 +3,24 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
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"
|
PLIST="$HOME/Library/LaunchAgents/cn.tkmind.goosed-monitor.plist"
|
||||||
LOG="$HOME/Library/Logs/goosed-monitor.log"
|
LOG="$HOME/Library/Logs/goosed-monitor.log"
|
||||||
GUI="gui/$(id -u)"
|
GUI="gui/$(id -u)"
|
||||||
|
|
||||||
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
|
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
|
cat > "$PLIST" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!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>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$NODE_BIN</string>
|
<string>$NODE_BIN</string>
|
||||||
<string>$ROOT/scripts/monitor-goosed-fds.mjs</string>
|
<string>$MONITOR_SCRIPT</string>
|
||||||
</array>
|
</array>
|
||||||
<key>WorkingDirectory</key>
|
<key>WorkingDirectory</key>
|
||||||
<string>$ROOT</string>
|
<string>$MONITOR_ROOT</string>
|
||||||
<key>StartInterval</key>
|
<key>StartInterval</key>
|
||||||
<integer>60</integer>
|
<integer>60</integer>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
@@ -55,4 +67,5 @@ launchctl enable "$GUI/cn.tkmind.goosed-monitor"
|
|||||||
launchctl kickstart -k "$GUI/cn.tkmind.goosed-monitor"
|
launchctl kickstart -k "$GUI/cn.tkmind.goosed-monitor"
|
||||||
|
|
||||||
echo "installed $PLIST"
|
echo "installed $PLIST"
|
||||||
|
echo "script: $MONITOR_SCRIPT"
|
||||||
echo "log: $LOG"
|
echo "log: $LOG"
|
||||||
|
|||||||
@@ -80,7 +80,39 @@ need_cmd scp
|
|||||||
need_cmd tar
|
need_cmd tar
|
||||||
need_cmd shasum
|
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 "本地预检查"
|
say "本地预检查"
|
||||||
|
check_release_scope
|
||||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null
|
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
|
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_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
|
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
|
||||||
say "发布确认"
|
say "发布确认"
|
||||||
echo "目标主机: ${HOST}"
|
echo "目标主机: ${HOST}"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ fi
|
|||||||
export NODE_ENV=production
|
export NODE_ENV=production
|
||||||
export H5_PORT="${H5_PORT:-8081}"
|
export H5_PORT="${H5_PORT:-8081}"
|
||||||
export H5_PUBLIC_BASE_URL="https://m.tkmind.cn"
|
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="${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}"
|
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;
|
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(
|
const existingPage = await mindSpacePages.findPageBySourceMessage(
|
||||||
req.currentUser.id,
|
req.currentUser.id,
|
||||||
sessionId,
|
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 未启用' });
|
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||||||
try {
|
try {
|
||||||
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
|
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
|
||||||
|
console.log('[quick-share] bundle resolved, hasHtml:', Boolean(bundle.resolvedHtml));
|
||||||
if (!bundle.resolvedHtml) {
|
if (!bundle.resolvedHtml) {
|
||||||
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1982,7 +1982,7 @@ export function MindSpaceView({
|
|||||||
{!agentJobsLoading && agentJobsList.length === 0 && (
|
{!agentJobsLoading && agentJobsList.length === 0 && (
|
||||||
<div className="mindspace-empty">
|
<div className="mindspace-empty">
|
||||||
<h2>还没有 AI 生成任务</h2>
|
<h2>还没有 AI 生成任务</h2>
|
||||||
<p>在 OA 工作区选择资料并创建页面任务后,任务会出现在这里。</p>
|
<p>在 OA 工作区选择资料并点击「AI 生成页面」后,任务会出现在这里。</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!agentJobsLoading && agentJobsList.length > 0 && (
|
{!agentJobsLoading && agentJobsList.length > 0 && (
|
||||||
|
|||||||
+5
-1
@@ -1662,6 +1662,11 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
try {
|
try {
|
||||||
const layout = await publishLayoutFor(user, { migrateLegacy: false });
|
const layout = await publishLayoutFor(user, { migrateLegacy: false });
|
||||||
sandboxMcp = {
|
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),
|
serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH),
|
||||||
sandboxRoot: layout.publishDir,
|
sandboxRoot: layout.publishDir,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -2709,7 +2714,6 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
policyCatalog: POLICY_CATALOG,
|
policyCatalog: POLICY_CATALOG,
|
||||||
skillCatalog,
|
skillCatalog,
|
||||||
publicUser,
|
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');
|
const skillPath = path.join(layout.publishDir, '.agents', 'skills', 'static-page-publish', 'SKILL.md');
|
||||||
assert.ok(fs.existsSync(skillPath));
|
assert.ok(fs.existsSync(skillPath));
|
||||||
const skillText = fs.readFileSync(skillPath, 'utf8');
|
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, /public\/report\.html/);
|
||||||
assert.match(skillText, /\[.*\]\(.*\)/);
|
assert.match(skillText, /\[.*\]\(.*\)/);
|
||||||
assert.match(skillText, /mindspace-cover/);
|
assert.match(skillText, /mindspace-cover/);
|
||||||
|
|||||||
+187
-10
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fetch as undiciFetch } from 'undici';
|
import { fetch as undiciFetch } from 'undici';
|
||||||
|
import { developerToolsFromPolicy } from './capabilities.mjs';
|
||||||
import { mergeMessageContent } from './message-stream.mjs';
|
import { mergeMessageContent } from './message-stream.mjs';
|
||||||
import { reconcileAgentSession } from './session-reconcile.mjs';
|
import { reconcileAgentSession } from './session-reconcile.mjs';
|
||||||
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.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 DEFAULT_PROGRESS_DELAY_MS = 8000;
|
||||||
const PUBLIC_HTML_LINK_PATTERN =
|
const PUBLIC_HTML_LINK_PATTERN =
|
||||||
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
|
/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) {
|
function parseXmlField(xml, field) {
|
||||||
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${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();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let messages = [];
|
let messages = [];
|
||||||
|
let hasScopedAssistantUpdate = false;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
@@ -209,16 +221,26 @@ async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metad
|
|||||||
if (hasActionRequired) {
|
if (hasActionRequired) {
|
||||||
throw new Error('当前回复需要人工确认,公众号通道暂不支持');
|
throw new Error('当前回复需要人工确认,公众号通道暂不支持');
|
||||||
}
|
}
|
||||||
|
if (event.message.role === 'assistant') hasScopedAssistantUpdate = true;
|
||||||
messages = pushMessage(messages, event.message);
|
messages = pushMessage(messages, event.message);
|
||||||
} else if (event.type === 'UpdateConversation') {
|
} 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') {
|
} else if (event.type === 'Error') {
|
||||||
throw new Error(event.error || '任务执行失败');
|
throw new Error(event.error || '任务执行失败');
|
||||||
} else if (event.type === 'Finish') {
|
} else if (event.type === 'Finish') {
|
||||||
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
|
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
|
||||||
|
if (!hasScopedAssistantUpdate || !assistant) {
|
||||||
|
throw new Error('本轮未收到可发送的新回复,请稍后重试');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
text: messageVisibleText(assistant),
|
text: messageVisibleText(assistant),
|
||||||
tokenState: event.token_state ?? null,
|
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 };
|
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) {
|
function stripInternalWechatUsername(text) {
|
||||||
return String(text ?? '').replace(/^\s*wx_[a-z0-9_]{4,64}\s*[,,、::]\s*/i, '');
|
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);
|
return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { maybeAttachPublishedHtmlLink };
|
||||||
|
|
||||||
function isQuestionStatusProbe(text) {
|
function isQuestionStatusProbe(text) {
|
||||||
return /^[??]+$/.test(String(text ?? '').trim());
|
return /^[??]+$/.test(String(text ?? '').trim());
|
||||||
}
|
}
|
||||||
@@ -1040,19 +1175,47 @@ export function createWechatMpService({
|
|||||||
if (forceNew) {
|
if (forceNew) {
|
||||||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
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);
|
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
|
||||||
if (existingRoute?.agentSessionId) {
|
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);
|
const gate = await userAuth.canUseChat(userId);
|
||||||
if (!gate.ok) {
|
if (!gate.ok) {
|
||||||
throw new Error(gate.message || '当前用户无法使用聊天能力');
|
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(
|
const started = await readJsonResponse(
|
||||||
await apiFetch('/agent/start', {
|
await apiFetch('/agent/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1073,7 +1236,6 @@ export function createWechatMpService({
|
|||||||
// `/agent/start` already persists the owning user and goosed node via the
|
// `/agent/start` already persists the owning user and goosed node via the
|
||||||
// portal proxy. Re-registering here without the node can overwrite the
|
// portal proxy. Re-registering here without the node can overwrite the
|
||||||
// correct mapping back to node 0 in multi-goosed production.
|
// correct mapping back to node 0 in multi-goosed production.
|
||||||
const addressName = resolveWechatAddressName(userContext);
|
|
||||||
await reconcileAgentSession(
|
await reconcileAgentSession(
|
||||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -1175,6 +1337,7 @@ export function createWechatMpService({
|
|||||||
userContext: user,
|
userContext: user,
|
||||||
forceNew,
|
forceNew,
|
||||||
});
|
});
|
||||||
|
const publishLayout = await userAuth.getUserPublishLayout(user.userId);
|
||||||
await ensureSessionProvider(sessionId);
|
await ensureSessionProvider(sessionId);
|
||||||
await rememberWechatUserContext(sessionId, user);
|
await rememberWechatUserContext(sessionId, user);
|
||||||
let finished = false;
|
let finished = false;
|
||||||
@@ -1220,15 +1383,22 @@ export function createWechatMpService({
|
|||||||
buildWechatAgentPrompt(intent),
|
buildWechatAgentPrompt(intent),
|
||||||
buildIntentMetadata(intent),
|
buildIntentMetadata(intent),
|
||||||
);
|
);
|
||||||
|
if (isSuspiciousBareCompletionReply(reply, intent)) {
|
||||||
|
throw new Error('stale_session_poisoned_completion');
|
||||||
|
}
|
||||||
if (reply.tokenState) {
|
if (reply.tokenState) {
|
||||||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
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 };
|
return { sessionId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const mayBeStaleSession =
|
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) {
|
if (mayBeStaleSession) {
|
||||||
sessionId = await ensureWechatAgentSession({
|
sessionId = await ensureWechatAgentSession({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
@@ -1246,10 +1416,17 @@ export function createWechatMpService({
|
|||||||
buildWechatAgentPrompt(intent),
|
buildWechatAgentPrompt(intent),
|
||||||
buildIntentMetadata(intent),
|
buildIntentMetadata(intent),
|
||||||
);
|
);
|
||||||
|
if (isSuspiciousBareCompletionReply(reply, intent)) {
|
||||||
|
throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试');
|
||||||
|
}
|
||||||
if (reply.tokenState) {
|
if (reply.tokenState) {
|
||||||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
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 };
|
return { sessionId };
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createWechatMpService,
|
createWechatMpService,
|
||||||
guardMissingPublicHtmlLinks,
|
guardMissingPublicHtmlLinks,
|
||||||
loadWechatMpConfig,
|
loadWechatMpConfig,
|
||||||
|
maybeAttachPublishedHtmlLink,
|
||||||
splitWechatText,
|
splitWechatText,
|
||||||
verifyWechatMpSignature,
|
verifyWechatMpSignature,
|
||||||
verifyWechatMpUrlChallenge,
|
verifyWechatMpUrlChallenge,
|
||||||
@@ -136,6 +137,48 @@ test('guardMissingPublicHtmlLinks blocks missing MindSpace public html links', a
|
|||||||
assert.match(guarded, /missing\.html/);
|
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 () => {
|
test('wechat mp service splits long agent replies into multiple customer messages', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
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/);
|
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', () => {
|
test('loadWechatMpConfig requires full config and enable flag', () => {
|
||||||
const config = loadWechatMpConfig({
|
const config = loadWechatMpConfig({
|
||||||
H5_WECHAT_MP_ENABLED: '1',
|
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 () => {
|
test('wechat mp service blocks schedule confirmation when no schedule item was written', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
|
|||||||
Reference in New Issue
Block a user