From 93811f4657bd9d4150812a65e31a5b1f29978e4d Mon Sep 17 00:00:00 2001 From: john Date: Fri, 17 Jul 2026 10:44:41 +0800 Subject: [PATCH] fix: connect Page Data sandbox to user PostgreSQL --- capabilities.mjs | 44 +++++++++++++++++++++++++- capabilities.test.mjs | 32 +++++++++++++++++++ page-data-collect-skill.test.mjs | 20 ++++++++++++ release-page-data-pg-guard.test.mjs | 16 ++++++++++ scripts/release-portal-runtime-prod.sh | 25 +++++++++++++++ skills/page-data-collect/SKILL.md | 20 ++++++++++-- user-auth.mjs | 15 ++++++++- user-auth.test.mjs | 12 ++++++- 8 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 page-data-collect-skill.test.mjs create mode 100644 release-page-data-pg-guard.test.mjs diff --git a/capabilities.mjs b/capabilities.mjs index 15dd348..6735cda 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -15,6 +15,33 @@ export function resolveSandboxMcpNodeExecPath(overridePath) { return process.execPath; } +const LOOPBACK_PG_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']); + +export function resolveSandboxMcpUserDataPgUrl({ + portalUrl, + mcpUrl, + containerized = false, + hostGateway = 'host.docker.internal', +} = {}) { + const explicitMcpUrl = String(mcpUrl ?? '').trim(); + if (explicitMcpUrl) return explicitMcpUrl; + + const sourceUrl = String(portalUrl ?? '').trim(); + if (!sourceUrl || !containerized) return sourceUrl; + + try { + const parsed = new URL(sourceUrl); + if (LOOPBACK_PG_HOSTS.has(parsed.hostname)) { + parsed.hostname = String(hostGateway || 'host.docker.internal').trim(); + } + return parsed.toString(); + } catch { + // Preserve the configured value so the MCP reports the real configuration + // error instead of silently falling back to its local Unix socket default. + return sourceUrl; + } +} + export function resolveMindSearchMcpServerPath(overridePath) { const normalized = String(overridePath ?? '').trim(); if (normalized) return normalized; @@ -311,6 +338,21 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) { if (sandboxMcp.workspaceRoot || localRoot) envs.MINDSPACE_WORKSPACE_ROOT = sandboxMcp.workspaceRoot || localRoot; if (sandboxMcp.workspaceRef) envs.MINDSPACE_WORKSPACE_REF = sandboxMcp.workspaceRef; if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId; + if (mcpTools.includes('private_data_info')) { + const userDataPgUrl = resolveSandboxMcpUserDataPgUrl({ + portalUrl: sandboxMcp.userDataPgUrl ?? process.env.MINDSPACE_USERDATA_PG_URL, + mcpUrl: sandboxMcp.userDataMcpPgUrl ?? process.env.MINDSPACE_USERDATA_MCP_PG_URL, + containerized: Boolean(sandboxMcp.containerized), + hostGateway: + sandboxMcp.userDataPgHostGateway ?? process.env.MINDSPACE_USERDATA_MCP_PG_HOST ?? 'host.docker.internal', + }); + envs.MINDSPACE_USERDATA_BACKEND = sandboxMcp.userDataBackend ?? process.env.MINDSPACE_USERDATA_BACKEND ?? 'postgres'; + if (userDataPgUrl) envs.MINDSPACE_USERDATA_PG_URL = userDataPgUrl; + const autoProvision = sandboxMcp.userDataAutoProvision ?? process.env.MINDSPACE_USERDATA_AUTO_PROVISION; + if (autoProvision != null && String(autoProvision).trim()) { + envs.MINDSPACE_USERDATA_AUTO_PROVISION = String(autoProvision).trim(); + } + } for (const key of [ 'DATABASE_URL', 'MYSQL_HOST', @@ -356,7 +398,7 @@ export function buildAgentExtensionPolicy( type: 'stdio', name: 'sandbox-fs', description: - '工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户唯一的 SQLite 数据库,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。', + '工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户隔离的 PostgreSQL schema,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。', display_name: 'sandbox-fs', bundled: false, cmd: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath), diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 1e4966a..78f4943 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -10,6 +10,7 @@ import { normalizeCapabilityPatch, resolveSandboxMcpNodeExecPath, resolveSandboxMcpServerPath, + resolveSandboxMcpUserDataPgUrl, sandboxDeveloperTools, sandboxMcpTools, } from './capabilities.mjs'; @@ -28,6 +29,30 @@ test('resolveSandboxMcpServerPath honors container-path override without host fs ); }); +test('resolveSandboxMcpUserDataPgUrl rewrites a portal loopback URL for container MCP access', () => { + const resolved = resolveSandboxMcpUserDataPgUrl({ + portalUrl: 'postgresql://mindspace:secret@127.0.0.1:5433/mindspace_userdata_prod', + containerized: true, + }); + const parsed = new URL(resolved); + assert.equal(parsed.hostname, 'host.docker.internal'); + assert.equal(parsed.port, '5433'); + assert.equal(parsed.pathname, '/mindspace_userdata_prod'); +}); + +test('resolveSandboxMcpUserDataPgUrl preserves native URLs and honors an explicit MCP URL', () => { + const portalUrl = 'postgresql://mindspace:secret@127.0.0.1:5433/mindspace_userdata_prod'; + assert.equal(resolveSandboxMcpUserDataPgUrl({ portalUrl }), portalUrl); + assert.equal( + resolveSandboxMcpUserDataPgUrl({ + portalUrl, + mcpUrl: 'postgresql://mindspace:secret@pg-proxy.internal:6432/mindspace_userdata_prod', + containerized: true, + }), + 'postgresql://mindspace:secret@pg-proxy.internal:6432/mindspace_userdata_prod', + ); +}); + test('resolveSandboxMcpNodeExecPath honors container-path override without host fs checks', () => { assert.equal(resolveSandboxMcpNodeExecPath('/usr/local/bin/node'), '/usr/local/bin/node'); assert.equal(resolveSandboxMcpNodeExecPath(''), process.execPath); @@ -274,11 +299,18 @@ test('private_data_space alone exposes private data tools through sandbox MCP', serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs', sandboxRoot: '/opt/h5/MindSpace/user-1', userId: 'user-1', + containerized: true, + userDataBackend: 'postgres', + userDataPgUrl: 'postgresql://mindspace:secret@127.0.0.1:5433/mindspace_userdata_prod', + userDataAutoProvision: '1', }, }); const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs'); assert.ok(sandboxExt); assert.equal(sandboxExt.envs.PRIVATE_DATA_USER_ID, 'user-1'); + assert.equal(new URL(sandboxExt.envs.MINDSPACE_USERDATA_PG_URL).hostname, 'host.docker.internal'); + assert.equal(sandboxExt.envs.MINDSPACE_USERDATA_BACKEND, 'postgres'); + assert.equal(sandboxExt.envs.MINDSPACE_USERDATA_AUTO_PROVISION, '1'); assert.deepEqual(sandboxExt.available_tools, [ 'private_data_info', 'private_data_schema', diff --git a/page-data-collect-skill.test.mjs b/page-data-collect-skill.test.mjs new file mode 100644 index 0000000..29b9d6f --- /dev/null +++ b/page-data-collect-skill.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const skillText = fs.readFileSync(new URL('./skills/page-data-collect/SKILL.md', import.meta.url), 'utf8'); + +test('page-data-collect skill uses PostgreSQL DDL and fails closed on private data errors', () => { + assert.match(skillText, /GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY/); + assert.match(skillText, /TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP/); + assert.doesNotMatch(skillText, /INTEGER PRIMARY KEY AUTOINCREMENT/); + assert.match(skillText, /数据层失败必须立即停止/); + assert.match(skillText, /禁止继续写 HTML、bind 或发布/); + assert.match(skillText, /平台没有延迟补执行队列/); +}); + +test('page-data-collect skill pins deletion to the public client API', () => { + assert.match(skillText, /client\.deleteRow\('dataset_name', rowId\)/); + assert.match(skillText, /禁止发明 `softDeleteRows`/); + assert.match(skillText, /表必须包含 `deleted_at TIMESTAMPTZ`/); +}); diff --git a/release-page-data-pg-guard.test.mjs b/release-page-data-pg-guard.test.mjs new file mode 100644 index 0000000..9391225 --- /dev/null +++ b/release-page-data-pg-guard.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const releaseScript = fs.readFileSync( + new URL('./scripts/release-portal-runtime-prod.sh', import.meta.url), + 'utf8', +); + +test('103 release preflight checks host and container access to the user-space PostgreSQL', () => { + assert.match(releaseScript, /MINDSPACE_USERDATA_PG_URL/); + assert.match(releaseScript, /user-space PostgreSQL URL cannot execute SELECT 1/); + assert.match(releaseScript, /host\.docker\.internal/); + assert.match(releaseScript, /port: 5433/); + assert.match(releaseScript, /cannot reach user-space PostgreSQL/); +}); diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index e10bc87..3421b46 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -203,6 +203,7 @@ set -euo pipefail missing=0 pg_isready_bin="/opt/homebrew/opt/postgresql@17/bin/pg_isready" +psql_bin="/opt/homebrew/opt/postgresql@17/bin/psql" if [[ -x "${pg_isready_bin}" ]]; then if ! "${pg_isready_bin}" -h 127.0.0.1 -p 5432 -q 2>/dev/null; then echo "goosed dependency check failed: host PostgreSQL (127.0.0.1:5432) is not accepting connections" >&2 @@ -213,6 +214,19 @@ else echo "goosed dependency check warning: pg_isready not found; skipping PostgreSQL readiness check" >&2 fi +portal_env="/Users/john/Project/Memind/.env" +userdata_pg_url="" +if [[ -f "${portal_env}" ]]; then + userdata_pg_url="$(grep -E '^MINDSPACE_USERDATA_PG_URL=' "${portal_env}" | tail -1 | cut -d= -f2- || true)" +fi +if [[ -z "${userdata_pg_url}" ]]; then + echo "goosed dependency check failed: Portal .env must set MINDSPACE_USERDATA_PG_URL" >&2 + missing=1 +elif [[ -x "${psql_bin}" ]] && ! "${psql_bin}" "${userdata_pg_url}" -Atc 'SELECT 1' >/dev/null 2>&1; then + echo "goosed dependency check failed: user-space PostgreSQL URL cannot execute SELECT 1" >&2 + missing=1 +fi + docker_bin="/opt/homebrew/bin/docker" goosed_containers=() goosed_indexes=() @@ -240,6 +254,17 @@ if ((${#goosed_containers[@]} > 0)); then echo "goosed dependency check failed: ${container} missing /usr/local/bin/node or /opt/portal/mindspace-sandbox-mcp.mjs" >&2 missing=1 fi + if ! "${docker_bin}" exec "${container}" /usr/local/bin/node -e ' + const net = require("net"); + const socket = net.connect({ host: "host.docker.internal", port: 5433 }); + const fail = () => process.exit(1); + socket.setTimeout(3000, fail); + socket.once("error", fail); + socket.once("connect", () => { socket.end(); process.exit(0); }); + ' >/dev/null 2>&1; then + echo "goosed dependency check failed: ${container} cannot reach user-space PostgreSQL at host.docker.internal:5433" >&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)" diff --git a/skills/page-data-collect/SKILL.md b/skills/page-data-collect/SKILL.md index 701a02f..74954bf 100644 --- a/skills/page-data-collect/SKILL.md +++ b/skills/page-data-collect/SKILL.md @@ -134,11 +134,11 @@ load_skill → page-data-collect ```sql CREATE TABLE IF NOT EXISTS survey_responses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, q1_feature TEXT NOT NULL, q2_usage TEXT NOT NULL, q3_suggestion TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')) + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); ``` @@ -157,6 +157,13 @@ CREATE TABLE IF NOT EXISTS survey_responses ( } ``` +**数据层失败必须立即停止(fail closed)**: + +- `private_data_execute`、`private_data_register_dataset`、`private_data_schema/query` 任一返回连接错误、权限错误或 `isError: true` 时,禁止继续写 HTML、bind 或发布。 +- 禁止声称“先准备 HTML,PG 恢复后会自动生效”;平台没有延迟补执行队列。数据库恢复后必须重新执行建表、注册、bind 和交付前自检。 +- PostgreSQL 连接错误应原样报告,不得把 `/tmp/.s.PGSQL.*`、`ECONNREFUSED` 或 `permission denied` 解释成“稍后会自动恢复”。 +- dataset 配置了 `soft_delete` 时,表必须包含 `deleted_at TIMESTAMPTZ`;配置了 `own_rows` 时,表必须包含 policy 指定的所有者字段。 + ### 3. 页面层:写 HTML - 用 `write_file` / `edit_file` 写入或更新 `public/*.html` @@ -167,6 +174,14 @@ CREATE TABLE IF NOT EXISTS survey_responses ( ``` +客户端只允许调用 `page-data-client.js` 已公开的方法:`listRows`、`getSchema`、`getStats`、`insertRow`、`updateRow`、`deleteRow`、`authenticate`。删除单行使用: + +```js +await client.deleteRow('dataset_name', rowId); +``` + +禁止发明 `softDeleteRows`、`deleteRows` 等不存在的方法;服务端会根据 dataset 的 `soft_delete` 授权把 `deleteRow` 转换为软删除。 + - **第三方 JS 库**(Chart.js、ECharts 等)禁止写 CDN `https://...`;发布页 CSP 只允许同源脚本。优先用平台预置路径,或下载到 `public/assets/` 后用相对路径引用: ```html @@ -287,6 +302,7 @@ CREATE TABLE IF NOT EXISTS survey_responses ( 3. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId 4. HTML **不含** `127.0.0.1:`、`/api/survey/`、`PLACEHOLDER_PAGE_ID` 5. 向用户说明:访客如何提交、管理员如何用口令查看记录 +6. HTML 未调用 `softDeleteRows` / `deleteRows` 等客户端不存在的方法;删除使用 `deleteRow(dataset, rowId)` ## 回复格式 diff --git a/user-auth.mjs b/user-auth.mjs index b3d5430..f2dc306 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -1870,7 +1870,7 @@ export function createUserAuth(pool, options = {}) { ); // Wire up the sandbox MCP for user workspace tools and the private data space. // File operations are OS-bound to the user's workspace; private_data_* tools - // only touch the user's single SQLite data space inside that workspace. + // only touch the user's isolated PostgreSQL schema. let sandboxMcp = null; if (effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space) { try { @@ -1879,6 +1879,13 @@ export function createUserAuth(pool, options = {}) { env, user, }); + const containerFlag = String(env.GOOSED_MCP_CONTAINERIZED ?? '').trim(); + const containerized = containerFlag + ? containerFlag === '1' + : Boolean( + env.GOOSED_MCP_NODE_PATH && + path.resolve(env.GOOSED_MCP_NODE_PATH) !== path.resolve(process.execPath), + ); 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) @@ -1891,6 +1898,12 @@ export function createUserAuth(pool, options = {}) { workspaceRef: workspaceCapability.workspaceRef, userId: user.id, nodeExecPath: env.GOOSED_MCP_NODE_PATH, + containerized, + userDataBackend: env.MINDSPACE_USERDATA_BACKEND, + userDataPgUrl: env.MINDSPACE_USERDATA_PG_URL, + userDataMcpPgUrl: env.MINDSPACE_USERDATA_MCP_PG_URL, + userDataPgHostGateway: env.MINDSPACE_USERDATA_MCP_PG_HOST, + userDataAutoProvision: env.MINDSPACE_USERDATA_AUTO_PROVISION, }; } catch (err) { console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message); diff --git a/user-auth.test.mjs b/user-auth.test.mjs index db2e92f..b626543 100644 --- a/user-auth.test.mjs +++ b/user-auth.test.mjs @@ -398,7 +398,14 @@ test('agent session policy preserves sandbox root and exposes workspace ref meta const auth = createUserAuth(createAgentPolicyPool(userRow), { h5Root: root, persistSessions: false, - env: { GOOSED_SANDBOX_PUBLISH_ROOT: '/srv/goosed-mindspace' }, + env: { + GOOSED_SANDBOX_PUBLISH_ROOT: '/srv/goosed-mindspace', + GOOSED_MCP_NODE_PATH: '/usr/local/bin/node', + GOOSED_MCP_SERVER_PATH: '/opt/portal/mindspace-sandbox-mcp.mjs', + MINDSPACE_USERDATA_BACKEND: 'postgres', + MINDSPACE_USERDATA_PG_URL: 'postgresql://mindspace:secret@127.0.0.1:5433/mindspace_userdata_prod', + MINDSPACE_USERDATA_AUTO_PROVISION: '1', + }, }); const policy = await auth.getAgentSessionPolicy(userRow.id); @@ -407,6 +414,9 @@ test('agent session policy preserves sandbox root and exposes workspace ref meta assert.equal(sandboxFs.envs.SANDBOX_ROOT, path.resolve('/srv/goosed-mindspace/user-1')); assert.equal(sandboxFs.envs.MINDSPACE_WORKSPACE_ROOT, path.resolve(root, 'MindSpace', 'user-1')); assert.equal(sandboxFs.envs.MINDSPACE_WORKSPACE_REF, 'mindspace://users/user-1/workspace'); + assert.equal(new URL(sandboxFs.envs.MINDSPACE_USERDATA_PG_URL).hostname, 'host.docker.internal'); + assert.equal(sandboxFs.envs.MINDSPACE_USERDATA_BACKEND, 'postgres'); + assert.equal(sandboxFs.envs.MINDSPACE_USERDATA_AUTO_PROVISION, '1'); }); test('admin capabilities include granted platform skills', async () => {