Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3659368927 | |||
| 93811f4657 | |||
| 4d57c35b66 | |||
| a5d109d585 | |||
| 331a863288 | |||
| 486e25f86b | |||
| f4bc716789 | |||
| 3cafadba5a | |||
| 122e478894 |
+43
-1
@@ -15,6 +15,33 @@ export function resolveSandboxMcpNodeExecPath(overridePath) {
|
|||||||
return process.execPath;
|
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) {
|
export function resolveMindSearchMcpServerPath(overridePath) {
|
||||||
const normalized = String(overridePath ?? '').trim();
|
const normalized = String(overridePath ?? '').trim();
|
||||||
if (normalized) return normalized;
|
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.workspaceRoot || localRoot) envs.MINDSPACE_WORKSPACE_ROOT = sandboxMcp.workspaceRoot || localRoot;
|
||||||
if (sandboxMcp.workspaceRef) envs.MINDSPACE_WORKSPACE_REF = sandboxMcp.workspaceRef;
|
if (sandboxMcp.workspaceRef) envs.MINDSPACE_WORKSPACE_REF = sandboxMcp.workspaceRef;
|
||||||
if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId;
|
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 [
|
for (const key of [
|
||||||
'DATABASE_URL',
|
'DATABASE_URL',
|
||||||
'MYSQL_HOST',
|
'MYSQL_HOST',
|
||||||
@@ -356,7 +398,7 @@ export function buildAgentExtensionPolicy(
|
|||||||
type: 'stdio',
|
type: 'stdio',
|
||||||
name: 'sandbox-fs',
|
name: 'sandbox-fs',
|
||||||
description:
|
description:
|
||||||
'工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户唯一的 SQLite 数据库,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。',
|
'工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户隔离的 PostgreSQL schema,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。',
|
||||||
display_name: 'sandbox-fs',
|
display_name: 'sandbox-fs',
|
||||||
bundled: false,
|
bundled: false,
|
||||||
cmd: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath),
|
cmd: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
normalizeCapabilityPatch,
|
normalizeCapabilityPatch,
|
||||||
resolveSandboxMcpNodeExecPath,
|
resolveSandboxMcpNodeExecPath,
|
||||||
resolveSandboxMcpServerPath,
|
resolveSandboxMcpServerPath,
|
||||||
|
resolveSandboxMcpUserDataPgUrl,
|
||||||
sandboxDeveloperTools,
|
sandboxDeveloperTools,
|
||||||
sandboxMcpTools,
|
sandboxMcpTools,
|
||||||
} from './capabilities.mjs';
|
} 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', () => {
|
test('resolveSandboxMcpNodeExecPath honors container-path override without host fs checks', () => {
|
||||||
assert.equal(resolveSandboxMcpNodeExecPath('/usr/local/bin/node'), '/usr/local/bin/node');
|
assert.equal(resolveSandboxMcpNodeExecPath('/usr/local/bin/node'), '/usr/local/bin/node');
|
||||||
assert.equal(resolveSandboxMcpNodeExecPath(''), process.execPath);
|
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',
|
serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs',
|
||||||
sandboxRoot: '/opt/h5/MindSpace/user-1',
|
sandboxRoot: '/opt/h5/MindSpace/user-1',
|
||||||
userId: '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');
|
const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs');
|
||||||
assert.ok(sandboxExt);
|
assert.ok(sandboxExt);
|
||||||
assert.equal(sandboxExt.envs.PRIVATE_DATA_USER_ID, 'user-1');
|
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, [
|
assert.deepEqual(sandboxExt.available_tools, [
|
||||||
'private_data_info',
|
'private_data_info',
|
||||||
'private_data_schema',
|
'private_data_schema',
|
||||||
|
|||||||
@@ -26,3 +26,13 @@ npm run verify:mindspace-page-sync-guards
|
|||||||
```
|
```
|
||||||
|
|
||||||
涉及 H5 交付时,还必须验证:未注册 dataset 时不产生可用 Page Data policy,且最终链接交付被拒绝或进入明确 repair 状态。
|
涉及 H5 交付时,还必须验证:未注册 dataset 时不产生可用 Page Data policy,且最终链接交付被拒绝或进入明确 repair 状态。
|
||||||
|
|
||||||
|
## PostgreSQL 用户空间角色守卫
|
||||||
|
|
||||||
|
生产用户空间 PostgreSQL(独立于 Goose session PostgreSQL)通过 `SET LOCAL ROLE ms_u_*_agent` 隔离每个用户。必须保留以下约束:
|
||||||
|
|
||||||
|
1. provisioning 必须显式授予运行连接用户 agent role 的 `SET` 权限。
|
||||||
|
2. agent role 保持 `INHERIT FALSE`;只允许显式 `SET ROLE` 后访问用户 schema,不能让连接用户默认继承全部用户权限。
|
||||||
|
3. 已存在的用户空间在首次访问时必须幂等检查并修复缺失的 `SET` 权限,不能只修复新注册用户。
|
||||||
|
4. 验收必须使用与生产等价的非超级用户连接完成 `SET LOCAL ROLE`、建表、dataset 注册和读写;超级用户会绕过角色切换限制,不能作为该问题的验收依据。
|
||||||
|
5. 修复角色授权时禁止修改用户 schema、表和数据;生产操作前保留用户空间 PG dump、角色授权快照和回滚 SQL。
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
1. 构建只发生在本机 Mac:`node scripts/build-portal-runtime.mjs`。
|
1. 构建只发生在本机 Mac:`node scripts/build-portal-runtime.mjs`。
|
||||||
2. 103 只接收 `.runtime/portal/` 打出来的 artifact,不直接覆盖源码树。
|
2. 103 只接收 `.runtime/portal/` 打出来的 artifact,不直接覆盖源码树。
|
||||||
|
- 所有由 goosed 以 stdio 启动的 Portal MCP 入口都必须打进 artifact,并同时加入发布脚本的必需文件和容器可见性检查;当前包括 `mindspace-sandbox-mcp.mjs` 与 `tkmind-search-mcp.mjs`。
|
||||||
3. 103 在切换前必须做 `Memind` 全量备份,并单独备份持久目录。
|
3. 103 在切换前必须做 `Memind` 全量备份,并单独备份持久目录。
|
||||||
4. 103 **禁止** `npm install`、`npm run build`、在线改源码后继续运行。
|
4. 103 **禁止** `npm install`、`npm run build`、在线改源码后继续运行。
|
||||||
5. 切换后必须通过 Portal 健康检查;失败立即回滚。
|
5. 切换后必须通过 Portal 健康检查;失败立即回滚。
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ export function quotePgIdentifier(value) {
|
|||||||
return `"${String(value).replaceAll('"', '""')}"`;
|
return `"${String(value).replaceAll('"', '""')}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quotePgLiteral(value) {
|
||||||
|
return `'${String(value).replaceAll("'", "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEnsureCurrentUserCanSetRoleSql(roleName) {
|
||||||
|
const role = String(roleName);
|
||||||
|
const roleLiteral = quotePgLiteral(role);
|
||||||
|
return `DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_auth_members am
|
||||||
|
JOIN pg_roles r ON r.oid = am.roleid
|
||||||
|
JOIN pg_roles m ON m.oid = am.member
|
||||||
|
WHERE r.rolname = ${roleLiteral} AND m.rolname = CURRENT_USER AND am.set_option
|
||||||
|
) THEN
|
||||||
|
EXECUTE format('GRANT %I TO %I WITH INHERIT FALSE, SET TRUE', ${roleLiteral}, CURRENT_USER);
|
||||||
|
END IF;
|
||||||
|
END $$`;
|
||||||
|
}
|
||||||
|
|
||||||
export function deriveUserSpaceNames(value) {
|
export function deriveUserSpaceNames(value) {
|
||||||
const userId = assertUserId(value);
|
const userId = assertUserId(value);
|
||||||
const compact = userId.replaceAll('-', '');
|
const compact = userId.replaceAll('-', '');
|
||||||
@@ -118,6 +137,7 @@ export function buildProvisionUserSql(userId, { sourceSqlitePath = null, quotaBy
|
|||||||
EXECUTE format('GRANT %I TO %I', '${names.ownerRole}', CURRENT_USER);
|
EXECUTE format('GRANT %I TO %I', '${names.ownerRole}', CURRENT_USER);
|
||||||
END IF;
|
END IF;
|
||||||
END $$`,
|
END $$`,
|
||||||
|
buildEnsureCurrentUserCanSetRoleSql(names.agentRole),
|
||||||
`CREATE SCHEMA IF NOT EXISTS ${schema} AUTHORIZATION ${owner}`,
|
`CREATE SCHEMA IF NOT EXISTS ${schema} AUTHORIZATION ${owner}`,
|
||||||
`REVOKE ALL ON SCHEMA ${schema} FROM PUBLIC`,
|
`REVOKE ALL ON SCHEMA ${schema} FROM PUBLIC`,
|
||||||
`GRANT USAGE, CREATE ON SCHEMA ${schema} TO ${agent}`,
|
`GRANT USAGE, CREATE ON SCHEMA ${schema} TO ${agent}`,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
buildControlSchemaSql,
|
buildControlSchemaSql,
|
||||||
|
buildEnsureCurrentUserCanSetRoleSql,
|
||||||
buildPostgresTableSql,
|
buildPostgresTableSql,
|
||||||
buildPostgresCheckSql,
|
buildPostgresCheckSql,
|
||||||
buildPostgresForeignKeySql,
|
buildPostgresForeignKeySql,
|
||||||
@@ -44,12 +45,21 @@ test('provision SQL creates isolated no-login roles and safety limits', () => {
|
|||||||
assert.match(sql, /CREATE ROLE "ms_u_ecc1c649fff7_owner" NOLOGIN/);
|
assert.match(sql, /CREATE ROLE "ms_u_ecc1c649fff7_owner" NOLOGIN/);
|
||||||
assert.match(sql, /CREATE ROLE "ms_u_ecc1c649fff7_agent" NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE/);
|
assert.match(sql, /CREATE ROLE "ms_u_ecc1c649fff7_agent" NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE/);
|
||||||
assert.match(sql, /pg_auth_members/);
|
assert.match(sql, /pg_auth_members/);
|
||||||
|
assert.match(sql, /r\.rolname = 'ms_u_ecc1c649fff7_agent'.*am\.set_option/s);
|
||||||
|
assert.match(sql, /GRANT %I TO %I WITH INHERIT FALSE, SET TRUE/);
|
||||||
assert.match(sql, /REVOKE ALL ON SCHEMA "u_ecc1c649fff74361a243a69b460cc407" FROM PUBLIC/);
|
assert.match(sql, /REVOKE ALL ON SCHEMA "u_ecc1c649fff74361a243a69b460cc407" FROM PUBLIC/);
|
||||||
assert.match(sql, /statement_timeout = '30s'/);
|
assert.match(sql, /statement_timeout = '30s'/);
|
||||||
assert.match(sql, /NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION/);
|
assert.match(sql, /NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION/);
|
||||||
assert.doesNotMatch(sql, /temp_file_limit/);
|
assert.doesNotMatch(sql, /temp_file_limit/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('agent role reconciliation requires SET without inherited privileges', () => {
|
||||||
|
const sql = buildEnsureCurrentUserCanSetRoleSql('ms_u_ecc1c649fff7_agent');
|
||||||
|
assert.match(sql, /m\.rolname = CURRENT_USER AND am\.set_option/);
|
||||||
|
assert.match(sql, /WITH INHERIT FALSE, SET TRUE/);
|
||||||
|
assert.doesNotMatch(sql, /WITH INHERIT TRUE/);
|
||||||
|
});
|
||||||
|
|
||||||
test('SQLite types and defaults map to conservative PostgreSQL equivalents', () => {
|
test('SQLite types and defaults map to conservative PostgreSQL equivalents', () => {
|
||||||
assert.equal(mapSqliteTypeToPostgres({ type: 'INTEGER', pk: 1 }), 'bigint GENERATED BY DEFAULT AS IDENTITY');
|
assert.equal(mapSqliteTypeToPostgres({ type: 'INTEGER', pk: 1 }), 'bigint GENERATED BY DEFAULT AS IDENTITY');
|
||||||
assert.equal(mapSqliteTypeToPostgres({ type: 'REAL', pk: 0 }), 'double precision');
|
assert.equal(mapSqliteTypeToPostgres({ type: 'REAL', pk: 0 }), 'double precision');
|
||||||
|
|||||||
+2
-2
@@ -58,7 +58,7 @@
|
|||||||
"test:scenario": "node scripts/run-scenario-test.mjs",
|
"test:scenario": "node scripts/run-scenario-test.mjs",
|
||||||
"verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs",
|
"verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs",
|
||||||
"test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
|
"test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
|
||||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
|
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
|
||||||
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
|
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
|
||||||
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
|
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
|
||||||
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
|
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime",
|
"verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime",
|
||||||
"verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs",
|
"verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs",
|
||||||
"verify:public-page-interaction": "node scripts/verify-public-page-interaction.mjs",
|
"verify:public-page-interaction": "node scripts/verify-public-page-interaction.mjs",
|
||||||
"verify:page-data": "node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs page-data-delivery-assess.test.mjs mindspace-page-data-finish-guard.test.mjs mindspace-page-data-finish-guard.integration.test.mjs",
|
"verify:page-data": "node --test mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs page-data-delivery-assess.test.mjs mindspace-page-data-finish-guard.test.mjs mindspace-page-data-finish-guard.integration.test.mjs",
|
||||||
"verify:page-data-delivery": "node scripts/repair-page-data-workspace-bindings.mjs --dry-run",
|
"verify:page-data-delivery": "node scripts/repair-page-data-workspace-bindings.mjs --dry-run",
|
||||||
"repair:page-data-bindings": "node scripts/repair-page-data-workspace-bindings.mjs",
|
"repair:page-data-bindings": "node scripts/repair-page-data-workspace-bindings.mjs",
|
||||||
"repair:page-data:103": "node scripts/ensure-page-data-datasets.mjs && node scripts/repair-page-data-workspace-bindings.mjs",
|
"repair:page-data:103": "node scripts/ensure-page-data-datasets.mjs && node scripts/repair-page-data-workspace-bindings.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`/);
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import {
|
import {
|
||||||
buildControlSchemaSql,
|
buildControlSchemaSql,
|
||||||
|
buildEnsureCurrentUserCanSetRoleSql,
|
||||||
deriveUserSpaceNames,
|
deriveUserSpaceNames,
|
||||||
provisionUserSpace,
|
provisionUserSpace,
|
||||||
quotePgIdentifier,
|
quotePgIdentifier,
|
||||||
@@ -107,6 +108,10 @@ export function createPostgresUserDataSpaceService(options = {}) {
|
|||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// PostgreSQL 17 grants CREATEROLE owners ADMIN but not SET by default.
|
||||||
|
// Reconcile existing spaces before the runtime executes SET LOCAL ROLE.
|
||||||
|
await client.query(buildEnsureCurrentUserCanSetRoleSql(names.agentRole));
|
||||||
}
|
}
|
||||||
provisionedUsers.add(names.userId);
|
provisionedUsers.add(names.userId);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createPostgresUserDataSpaceService } from './postgres-user-data-space-service.mjs';
|
||||||
|
|
||||||
|
const USER_ID = '18a143d1-3ac5-42b3-a4af-d07f8445bce6';
|
||||||
|
|
||||||
|
test('existing PostgreSQL user spaces reconcile agent SET permission before tenant access', async () => {
|
||||||
|
const queries = [];
|
||||||
|
const client = {
|
||||||
|
async query(sql) {
|
||||||
|
queries.push(String(sql));
|
||||||
|
if (String(sql).includes('SELECT migration_state FROM mindspace_control.user_spaces')) {
|
||||||
|
return { rows: [{ migration_state: 'cutover' }] };
|
||||||
|
}
|
||||||
|
if (String(sql).includes('FROM information_schema.columns')) return { rows: [] };
|
||||||
|
return { rows: [] };
|
||||||
|
},
|
||||||
|
release() {},
|
||||||
|
};
|
||||||
|
const service = createPostgresUserDataSpaceService({
|
||||||
|
userId: USER_ID,
|
||||||
|
pgPool: { connect: async () => client },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.getSchema();
|
||||||
|
|
||||||
|
const reconcileIndex = queries.findIndex((sql) => (
|
||||||
|
sql.includes("r.rolname = 'ms_u_18a143d13ac5_agent'")
|
||||||
|
&& sql.includes('WITH INHERIT FALSE, SET TRUE')
|
||||||
|
));
|
||||||
|
const setRoleIndex = queries.findIndex((sql) => sql.includes('SET LOCAL ROLE "ms_u_18a143d13ac5_agent"'));
|
||||||
|
assert.ok(reconcileIndex >= 0, 'existing space must reconcile the agent role membership');
|
||||||
|
assert.ok(setRoleIndex > reconcileIndex, 'role reconciliation must happen before SET LOCAL ROLE');
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -143,6 +143,26 @@ async function bundleSandboxMcp() {
|
|||||||
await run(esbuildBin, args);
|
await run(esbuildBin, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bundleMindSearchMcp() {
|
||||||
|
if (!(await exists(esbuildBin))) {
|
||||||
|
throw new Error(`未找到 esbuild: ${esbuildBin}`);
|
||||||
|
}
|
||||||
|
console.log('==> 打包 MindSearch MCP 为单文件 runtime');
|
||||||
|
const args = [
|
||||||
|
'tkmind-search-mcp.mjs',
|
||||||
|
'--bundle',
|
||||||
|
'--platform=node',
|
||||||
|
'--format=esm',
|
||||||
|
`--target=${runtimeNodeTarget}`,
|
||||||
|
'--outfile=.runtime/portal/tkmind-search-mcp.mjs',
|
||||||
|
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||||
|
];
|
||||||
|
for (const pkg of externalPackages) {
|
||||||
|
args.push(`--external:${pkg}`);
|
||||||
|
}
|
||||||
|
await run(esbuildBin, args);
|
||||||
|
}
|
||||||
|
|
||||||
async function bundleAgentRunWorker() {
|
async function bundleAgentRunWorker() {
|
||||||
if (!(await exists(esbuildBin))) {
|
if (!(await exists(esbuildBin))) {
|
||||||
throw new Error(`未找到 esbuild: ${esbuildBin}`);
|
throw new Error(`未找到 esbuild: ${esbuildBin}`);
|
||||||
@@ -414,6 +434,7 @@ async function writeMetadata() {
|
|||||||
'',
|
'',
|
||||||
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
|
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
|
||||||
' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)',
|
' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)',
|
||||||
|
' tkmind-search-mcp.mjs (esbuild bundle; required when MindSearch is enabled)',
|
||||||
' wechat-mp.bundle.mjs (esbuild bundle; hot-swappable WeChat MP module)',
|
' wechat-mp.bundle.mjs (esbuild bundle; hot-swappable WeChat MP module)',
|
||||||
'',
|
'',
|
||||||
'Post-deploy validation (scripts/check-mindspace-public-links.mjs):',
|
'Post-deploy validation (scripts/check-mindspace-public-links.mjs):',
|
||||||
@@ -486,6 +507,7 @@ async function main() {
|
|||||||
await bundleServer();
|
await bundleServer();
|
||||||
await bundleWechatMp();
|
await bundleWechatMp();
|
||||||
await bundleSandboxMcp();
|
await bundleSandboxMcp();
|
||||||
|
await bundleMindSearchMcp();
|
||||||
await bundleAgentRunWorker();
|
await bundleAgentRunWorker();
|
||||||
if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) {
|
if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) {
|
||||||
await copyNodeModules();
|
await copyNodeModules();
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ fi
|
|||||||
|
|
||||||
verify_runtime_artifact() {
|
verify_runtime_artifact() {
|
||||||
local missing=0
|
local missing=0
|
||||||
for required in server.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
for required in server.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
||||||
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
||||||
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
||||||
missing=1
|
missing=1
|
||||||
@@ -203,6 +203,7 @@ set -euo pipefail
|
|||||||
missing=0
|
missing=0
|
||||||
|
|
||||||
pg_isready_bin="/opt/homebrew/opt/postgresql@17/bin/pg_isready"
|
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 [[ -x "${pg_isready_bin}" ]]; then
|
||||||
if ! "${pg_isready_bin}" -h 127.0.0.1 -p 5432 -q 2>/dev/null; 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
|
echo "goosed dependency check failed: host PostgreSQL (127.0.0.1:5432) is not accepting connections" >&2
|
||||||
@@ -213,22 +214,39 @@ else
|
|||||||
echo "goosed dependency check warning: pg_isready not found; skipping PostgreSQL readiness check" >&2
|
echo "goosed dependency check warning: pg_isready not found; skipping PostgreSQL readiness check" >&2
|
||||||
fi
|
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"
|
docker_bin="/opt/homebrew/bin/docker"
|
||||||
if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/null | grep -q '^goosed-prod-1$'; then
|
goosed_containers=()
|
||||||
goosed_indexes=()
|
goosed_indexes=()
|
||||||
while IFS= read -r name; do
|
if [[ -x "${docker_bin}" ]]; then
|
||||||
if [[ "${name}" =~ ^goosed-prod-([0-9]+)$ ]]; then
|
while IFS='|' read -r name service; do
|
||||||
|
if [[ "${service}" =~ ^goosed-([0-9]+)$ ]]; then
|
||||||
|
goosed_containers+=("${name}")
|
||||||
goosed_indexes+=("${BASH_REMATCH[1]}")
|
goosed_indexes+=("${BASH_REMATCH[1]}")
|
||||||
fi
|
fi
|
||||||
done < <("${docker_bin}" ps --format '{{.Names}}' | sort -V)
|
done < <("${docker_bin}" ps \
|
||||||
if ((${#goosed_indexes[@]} == 0)); then
|
--filter 'label=com.docker.compose.project=goosed-prod' \
|
||||||
echo "goosed dependency check failed: no goosed-prod-* containers running" >&2
|
--format '{{.Names}}|{{.Label "com.docker.compose.service"}}')
|
||||||
missing=1
|
fi
|
||||||
fi
|
if ((${#goosed_containers[@]} > 0)); then
|
||||||
for index in "${goosed_indexes[@]}"; do
|
for position in "${!goosed_containers[@]}"; do
|
||||||
container="goosed-prod-${index}"
|
container="${goosed_containers[$position]}"
|
||||||
|
index="${goosed_indexes[$position]}"
|
||||||
host_port=$((18005 + 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
|
health="$("${docker_bin}" inspect "${container}" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' 2>/dev/null || true)"
|
||||||
|
if [[ "${health}" != "healthy" ]] || ! "${docker_bin}" port "${container}" 18006/tcp 2>/dev/null | grep -q "0.0.0.0:${host_port}$"; then
|
||||||
echo "goosed dependency check failed: ${container} is not healthy on host port ${host_port}" >&2
|
echo "goosed dependency check failed: ${container} is not healthy on host port ${host_port}" >&2
|
||||||
missing=1
|
missing=1
|
||||||
fi
|
fi
|
||||||
@@ -236,6 +254,17 @@ if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/n
|
|||||||
echo "goosed dependency check failed: ${container} missing /usr/local/bin/node or /opt/portal/mindspace-sandbox-mcp.mjs" >&2
|
echo "goosed dependency check failed: ${container} missing /usr/local/bin/node or /opt/portal/mindspace-sandbox-mcp.mjs" >&2
|
||||||
missing=1
|
missing=1
|
||||||
fi
|
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
|
done
|
||||||
if [[ -f /Users/john/Project/Memind/.env ]]; then
|
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_node="$(grep -E '^GOOSED_MCP_NODE_PATH=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)"
|
||||||
@@ -244,8 +273,7 @@ if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/n
|
|||||||
echo "goosed dependency check failed: Portal .env must set GOOSED_MCP_NODE_PATH and GOOSED_MCP_SERVER_PATH for Docker goosed" >&2
|
echo "goosed dependency check failed: Portal .env must set GOOSED_MCP_NODE_PATH and GOOSED_MCP_SERVER_PATH for Docker goosed" >&2
|
||||||
missing=1
|
missing=1
|
||||||
else
|
else
|
||||||
for index in "${goosed_indexes[@]}"; do
|
for container in "${goosed_containers[@]}"; 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
|
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
|
echo "goosed dependency check failed: ${container} cannot resolve Portal .env MCP paths '${portal_mcp_node}' and '${portal_mcp_server}'" >&2
|
||||||
missing=1
|
missing=1
|
||||||
@@ -627,6 +655,7 @@ say "检查 live 目录中不再保留源码树"
|
|||||||
allowed_live_mjs=(
|
allowed_live_mjs=(
|
||||||
"${APP_DIR}/server.mjs"
|
"${APP_DIR}/server.mjs"
|
||||||
"${APP_DIR}/mindspace-sandbox-mcp.mjs"
|
"${APP_DIR}/mindspace-sandbox-mcp.mjs"
|
||||||
|
"${APP_DIR}/tkmind-search-mcp.mjs"
|
||||||
"${APP_DIR}/mindspace-public-links.mjs"
|
"${APP_DIR}/mindspace-public-links.mjs"
|
||||||
)
|
)
|
||||||
extra_files=""
|
extra_files=""
|
||||||
|
|||||||
@@ -134,11 +134,11 @@ load_skill → page-data-collect
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE IF NOT EXISTS survey_responses (
|
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,
|
q1_feature TEXT NOT NULL,
|
||||||
q2_usage TEXT NOT NULL,
|
q2_usage TEXT NOT NULL,
|
||||||
q3_suggestion 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
|
### 3. 页面层:写 HTML
|
||||||
|
|
||||||
- 用 `write_file` / `edit_file` 写入或更新 `public/*.html`
|
- 用 `write_file` / `edit_file` 写入或更新 `public/*.html`
|
||||||
@@ -167,6 +174,14 @@ CREATE TABLE IF NOT EXISTS survey_responses (
|
|||||||
<script src="/assets/page-data-client.js"></script>
|
<script src="/assets/page-data-client.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
客户端只允许调用 `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/` 后用相对路径引用:
|
- **第三方 JS 库**(Chart.js、ECharts 等)禁止写 CDN `https://...`;发布页 CSP 只允许同源脚本。优先用平台预置路径,或下载到 `public/assets/` 后用相对路径引用:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
@@ -287,6 +302,7 @@ CREATE TABLE IF NOT EXISTS survey_responses (
|
|||||||
3. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId
|
3. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId
|
||||||
4. HTML **不含** `127.0.0.1:`、`/api/survey/`、`PLACEHOLDER_PAGE_ID`
|
4. HTML **不含** `127.0.0.1:`、`/api/survey/`、`PLACEHOLDER_PAGE_ID`
|
||||||
5. 向用户说明:访客如何提交、管理员如何用口令查看记录
|
5. 向用户说明:访客如何提交、管理员如何用口令查看记录
|
||||||
|
6. HTML 未调用 `softDeleteRows` / `deleteRows` 等客户端不存在的方法;删除使用 `deleteRow(dataset, rowId)`
|
||||||
|
|
||||||
## 回复格式
|
## 回复格式
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -1870,7 +1870,7 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
);
|
);
|
||||||
// Wire up the sandbox MCP for user workspace tools and the private data space.
|
// 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
|
// 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;
|
let sandboxMcp = null;
|
||||||
if (effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space) {
|
if (effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space) {
|
||||||
try {
|
try {
|
||||||
@@ -1879,6 +1879,13 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
env,
|
env,
|
||||||
user,
|
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 = {
|
sandboxMcp = {
|
||||||
// When goosed runs in a container its filesystem is split from the portal's,
|
// 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)
|
// 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,
|
workspaceRef: workspaceCapability.workspaceRef,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
nodeExecPath: env.GOOSED_MCP_NODE_PATH,
|
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) {
|
} catch (err) {
|
||||||
console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message);
|
console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message);
|
||||||
|
|||||||
+12
-1
@@ -398,7 +398,15 @@ test('agent session policy preserves sandbox root and exposes workspace ref meta
|
|||||||
const auth = createUserAuth(createAgentPolicyPool(userRow), {
|
const auth = createUserAuth(createAgentPolicyPool(userRow), {
|
||||||
h5Root: root,
|
h5Root: root,
|
||||||
persistSessions: false,
|
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',
|
||||||
|
GOOSED_MCP_CONTAINERIZED: '1',
|
||||||
|
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);
|
const policy = await auth.getAgentSessionPolicy(userRow.id);
|
||||||
@@ -407,6 +415,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.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_ROOT, path.resolve(root, 'MindSpace', 'user-1'));
|
||||||
assert.equal(sandboxFs.envs.MINDSPACE_WORKSPACE_REF, 'mindspace://users/user-1/workspace');
|
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 () => {
|
test('admin capabilities include granted platform skills', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user