diff --git a/.env.example b/.env.example index 762e242..2dc50cb 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,22 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # MEMIND_AGENT_RUN_WORKER_ID= # MEMIND_AGENT_RUN_WORKER_EXPECT_RUNNING=0 +# 本机 Aider / OpenHands(Colima 迁出后走 native CLI,见 memind-local-aider / memind-local-openhands) +# AIDER_BIN=/Users/john/.local/bin/aider +# GOOSE_AIDER_BIN=/Users/john/.openhands/bin/studio-aider +# OPENHANDS_BIN=/Users/john/.local/bin/openhands +# GOOSE_AIDER_BIN=/Users/john/Project/Memind/scripts/aider-goose-wrapper.sh +# GOOSE_OPENHANDS_BIN=/Users/john/Project/Memind/scripts/openhands-goose-wrapper.sh +# GOOSE_OPENHANDS_RUNNER=host +# 本地开发:普通用户 role=user 默认开启 aider/openhands;生产保持 0 +# MEMIND_ROLE_CODE_TOOLS_DEFAULT=0 +# MEMIND_TOOL_GATEWAY_ENABLED=0 +# MEMIND_TOOL_GATEWAY_DRY_RUN=0 +# MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR=aider +# Orchestrator worker(memind-local-orchestrator/.env 同步)OpenHands 进程执行器: +# MEMIND_EXECUTOR_OPENHANDS_COMMAND=/Users/john/Project/Memind/scripts/openhands-goose-wrapper.sh +# MEMIND_EXECUTOR_WORKSPACE_ROOT=/Users/john/Project/Memind + # LangGraph Orchestrator Shadow(默认完全不接入 Agent Run 请求路径)。 # 仅在独立 Orchestrator 已健康、service token 已配置后,先显式打开观察 wiring, # 再通过 memindadm 选择 shadow。关闭 wiring 时不查询编排配置、不排队、不传用户数据。 @@ -331,6 +347,10 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # MindSearch / standalone Deep Search # --------------------------------------------------------------------------- # Production SearXNG on 103 is bound to the host at 127.0.0.1:20080. +# Outbound proxy for DuckDuckGo/Yandex lives in searxng-runtime shared config: +# /Users/john/Project/searxng-runtime/shared/.env +# SEARXNG_OUTGOING_PROXY=http://127.0.0.1:1082 (Shadowrocket MacPacket) +# Template source: /Users/john/Project/searxng-prod/settings.yml.template # Goosed MCP endpoints are automatically rewritten to host.docker.internal. # TKMIND_SEARCH_SEARXNG_URL=http://127.0.0.1:20080/search # TKMIND_SEARCH_MCP_HOST_GATEWAY=host.docker.internal diff --git a/conversation-display.mjs b/conversation-display.mjs index fc81230..53ed7aa 100644 --- a/conversation-display.mjs +++ b/conversation-display.mjs @@ -32,8 +32,17 @@ export function stripMindSpaceContextPrefix(text) { return next; } -const ASSISTANT_DELIVERABLE_RE = - /\[[^\]]+\]\(https?:\/\/[^)]+\)|https?:\/\/(?:m\.)?[^/\s]*tkmind\.(?:cn|ai)\/MindSpace\//i; +/** MindSpace 交付链接:生产域名与本地 dev(127.0.0.1 / localhost)都必须可点击,不可被架构脱敏替换。 */ +export const MINDSPACE_DELIVERABLE_ORIGIN_RE = + '(?:127\\.0\\.0\\.1|localhost):\\d+|(?:m\\.)?[^/\\s]*tkmind\\.(?:cn|ai)'; + +const ASSISTANT_DELIVERABLE_RE = new RegExp( + [ + '\\[[^\\]]+\\]\\(https?:\\/\\/[^)]+\\)', + `https?:\\/\\/${MINDSPACE_DELIVERABLE_ORIGIN_RE}\\/MindSpace\\/`, + ].join('|'), + 'i', +); const INTERNAL_ASSISTANT_MARKERS = [ /data-mindspace-page-tag/i, diff --git a/conversation-display.test.mjs b/conversation-display.test.mjs index 1b66903..b99d9bb 100644 --- a/conversation-display.test.mjs +++ b/conversation-display.test.mjs @@ -89,3 +89,13 @@ test('deriveAssistantFacingText keeps user-facing replies with public links', () '页面已生成:[AI 机器人研究报告 2026](https://m.tkmind.cn/MindSpace/john/public/ai-robot-report.html)'; assert.equal(deriveAssistantFacingText(external), external); }); + +test('deriveAssistantFacingText keeps local MindSpace deliverable links clickable', () => { + const localMarkdown = + '日记本就绪:[打开日记本](http://127.0.0.1:8081/MindSpace/32035858-9a20-425b-89da-c118ef0779aa/public/diary.html)'; + assert.equal(deriveAssistantFacingText(localMarkdown), localMarkdown); + + const localBare = + '本地预览:http://127.0.0.1:5173/MindSpace/john/public/survey.html'; + assert.equal(deriveAssistantFacingText(localBare), localBare); +}); diff --git a/docs/openhands-install.md b/docs/openhands-install.md index 63a6284..236b569 100644 --- a/docs/openhands-install.md +++ b/docs/openhands-install.md @@ -1,5 +1,10 @@ # OpenHands 安装说明 +> **本机 Colima 已迁出(2026-07-29)**。日常开发请优先使用 +> [`/Users/john/Project/memind-local-openhands`](../memind-local-openhands/README.md) +> 与 [`memind-local-infra/MIGRATION.md`](/Users/john/Project/memind-local-infra/MIGRATION.md)。 +> 下文 Colima compose 章节仅作历史参考。 + > 目标:先把 OpenHands 独立安装并跑起来,确认可用后,再接入 Goose / 路由策略。 > > 原则:安装阶段不改动现有 Goose 服务,不影响当前生产/测试链路。 diff --git a/docs/regression-guards/page-data-delivery-contract.md b/docs/regression-guards/page-data-delivery-contract.md index aca6ff3..fb72bc3 100644 --- a/docs/regression-guards/page-data-delivery-contract.md +++ b/docs/regression-guards/page-data-delivery-contract.md @@ -7,6 +7,16 @@ Page Data 页面只有在下面所有条件满足后才可向用户交付链接 3. page record、online publication 与 policy 使用同一个真实 page UUID。 4. 公开页完成一次受权限约束的 insert smoke;后台页完成 password auth 后的 read smoke。 +## 页面空间配额(grace write) + +MindSpace **页面 HTML**(`public/*.html`、`draft/*.html`)配额策略: + +- **尚未超配额**但剩余空间不足:允许当次页面写完并完成 bind(grace write)。 +- **已经超配额**(`used_bytes + reserved_bytes >= quota_bytes`):下一次 `write_file` / `edit_file` / bind / 发布必须直接失败,提示用户先清理空间。 +- grace **不覆盖**图片/附件等非页面 HTML 资产。 + +Agent 不得在空间已满时交付 Page Data 链接;不得只写 slug policy 文件而不完成 `private_data_bind_workspace_page`。 + `private_data_bind_workspace_page` 是硬门:它在创建 page / publication / policy 前,必须从 SQLite registry 派生权限。Agent 传入的策略不能凭空创建 dataset 或字段权限。 运行时路径必须按语义区分: diff --git a/mindspace-local-runtime-services.mjs b/mindspace-local-runtime-services.mjs index 66ac407..19174b7 100644 --- a/mindspace-local-runtime-services.mjs +++ b/mindspace-local-runtime-services.mjs @@ -128,6 +128,7 @@ export function createMindSpaceLocalRuntimeServices({ syncWorkspaceAssets, conversationArtifactService, workspacePageDeliveryService, + getQuotaPool: () => pool, }); const cleanupService = createCleanupService(pool, { storageRoot: runtime.storageRoot, diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 904e511..cff7aa1 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -22,6 +22,7 @@ import { prepareHtmlPageBrandMarkers } from './mindspace-page-tag.mjs'; import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs'; import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs'; +import { assertPageWriteQuota } from './mindspace-space-quota.mjs'; const MAX_TITLE_LENGTH = 255; const MAX_SUMMARY_LENGTH = 1000; @@ -393,13 +394,10 @@ export function createPageService(pool, options = {}) { if (!space || space.status !== 'active') { throw pageError('用户空间不可用', 'space_unavailable'); } - const available = - asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes); - if (available < normalized.contentBytes) { - throw pageError('剩余空间不足', 'quota_exceeded', { - requiredBytes: normalized.contentBytes, - availableBytes: Math.max(0, available), - }); + try { + assertPageWriteQuota(space, normalized.contentBytes); + } catch (error) { + throw pageError(error.message, error.code ?? 'quota_exceeded', error.details); } const [categories] = await conn.query( `SELECT id, category_code FROM h5_space_categories diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index ad8be2a..6c89b30 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -21,6 +21,7 @@ import { inferWorkspaceHtmlRelativePath, } from './mindspace-html-download-links.mjs'; import { createImgproxySigner } from './imgproxy-signer.mjs'; +import { assertPageWriteQuota } from './mindspace-space-quota.mjs'; const SCANNER_VERSION = 'mindspace-content-v1'; const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN = @@ -723,15 +724,10 @@ export function createPublicationService(pool, options = {}) { WHERE id = ? AND user_id = ? FOR UPDATE`, [page.space_id, userId], ); - const available = - Number(spaces[0]?.quota_bytes ?? 0) - - Number(spaces[0]?.used_bytes ?? 0) - - Number(spaces[0]?.reserved_bytes ?? 0); - if (available < htmlBytes) { - throw publicationError('剩余空间不足', 'quota_exceeded', { - requiredBytes: htmlBytes, - availableBytes: Math.max(0, available), - }); + try { + assertPageWriteQuota(spaces[0], htmlBytes); + } catch (error) { + throw publicationError(error.message, error.code ?? 'quota_exceeded', error.details); } const [publicationUsage] = await conn.query( `SELECT COUNT(DISTINCT page_id) AS public_page_used, diff --git a/mindspace-space-quota.mjs b/mindspace-space-quota.mjs new file mode 100644 index 0000000..983da9f --- /dev/null +++ b/mindspace-space-quota.mjs @@ -0,0 +1,110 @@ +function asQuotaNumber(value) { + return Number(value ?? 0); +} + +export function computeUserSpaceQuotaStats(space = {}) { + const quotaBytes = asQuotaNumber(space.quota_bytes); + const usedBytes = asQuotaNumber(space.used_bytes); + const reservedBytes = asQuotaNumber(space.reserved_bytes); + const occupiedBytes = usedBytes + reservedBytes; + const availableBytes = quotaBytes - occupiedBytes; + return { + quotaBytes, + usedBytes, + reservedBytes, + occupiedBytes, + availableBytes, + isOverQuota: occupiedBytes >= quotaBytes, + }; +} + +/** + * Page write grace policy: + * - Already at/above quota: block the next page write entirely. + * - Below quota but insufficient headroom: allow this write (may exceed quota). + * - Sufficient headroom: allow normally. + */ +export function evaluatePageWriteQuota(space, requiredBytes) { + const stats = computeUserSpaceQuotaStats(space); + const required = Math.max(0, asQuotaNumber(requiredBytes)); + if (stats.isOverQuota) { + return { + allowed: false, + grace: false, + stats, + requiredBytes: required, + reason: 'over_quota', + }; + } + if (stats.availableBytes >= required) { + return { + allowed: true, + grace: false, + stats, + requiredBytes: required, + reason: 'sufficient', + }; + } + return { + allowed: true, + grace: true, + stats, + requiredBytes: required, + reason: 'grace_write', + }; +} + +export function buildPageWriteQuotaError( + evaluation, + { messagePrefix = '剩余空间不足' } = {}, +) { + const { stats, requiredBytes, reason } = evaluation; + const message = + reason === 'over_quota' + ? '空间已满,请先清理后再创建或修改页面' + : messagePrefix; + return Object.assign(new Error(message), { + code: 'quota_exceeded', + details: { + requiredBytes, + availableBytes: Math.max(0, stats.availableBytes), + usedBytes: stats.usedBytes, + quotaBytes: stats.quotaBytes, + overQuota: reason === 'over_quota', + graceWrite: reason === 'grace_write', + }, + }); +} + +export function assertPageWriteQuota(space, requiredBytes) { + const evaluation = evaluatePageWriteQuota(space, requiredBytes); + if (!evaluation.allowed) { + throw buildPageWriteQuotaError(evaluation); + } + return evaluation; +} + +export function isMindSpacePageWriteRelativePath(relativePath) { + const normalized = String(relativePath ?? '').replace(/\\/g, '/').trim(); + if (!normalized.toLowerCase().endsWith('.html')) return false; + const zone = normalized.split('/')[0]?.toLowerCase(); + return zone === 'public' || zone === 'draft'; +} + +export async function loadUserSpaceForQuota(pool, userId) { + if (!pool || !userId) return null; + const [rows] = await pool.query( + `SELECT id, quota_bytes, used_bytes, reserved_bytes, status + FROM h5_user_spaces WHERE user_id = ? LIMIT 1`, + [userId], + ); + return rows[0] ?? null; +} + +export async function assertPageWriteQuotaForUser(pool, userId, requiredBytes) { + const space = await loadUserSpaceForQuota(pool, userId); + if (!space || space.status !== 'active') { + throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' }); + } + return assertPageWriteQuota(space, requiredBytes); +} diff --git a/mindspace-space-quota.test.mjs b/mindspace-space-quota.test.mjs new file mode 100644 index 0000000..fe39973 --- /dev/null +++ b/mindspace-space-quota.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + assertPageWriteQuota, + evaluatePageWriteQuota, + isMindSpacePageWriteRelativePath, +} from './mindspace-space-quota.mjs'; + +const baseSpace = { + quota_bytes: 100, + used_bytes: 80, + reserved_bytes: 0, +}; + +test('evaluatePageWriteQuota allows normal writes within headroom', () => { + assert.deepEqual(evaluatePageWriteQuota(baseSpace, 15).reason, 'sufficient'); +}); + +test('evaluatePageWriteQuota allows grace write when headroom is insufficient but not over quota', () => { + const result = evaluatePageWriteQuota(baseSpace, 30); + assert.equal(result.reason, 'grace_write'); + assert.equal(result.allowed, true); + assert.equal(result.grace, true); +}); + +test('evaluatePageWriteQuota blocks next write after quota is exhausted', () => { + const over = { quota_bytes: 100, used_bytes: 100, reserved_bytes: 0 }; + const result = evaluatePageWriteQuota(over, 1); + assert.equal(result.reason, 'over_quota'); + assert.equal(result.allowed, false); +}); + +test('assertPageWriteQuota throws over-quota message for subsequent writes', () => { + const over = { quota_bytes: 100, used_bytes: 101, reserved_bytes: 0 }; + assert.throws( + () => assertPageWriteQuota(over, 1), + (error) => + error.code === 'quota_exceeded' && + error.message.includes('空间已满') && + error.details?.overQuota === true, + ); +}); + +test('isMindSpacePageWriteRelativePath matches public and draft html only', () => { + assert.equal(isMindSpacePageWriteRelativePath('public/bug-tracker.html'), true); + assert.equal(isMindSpacePageWriteRelativePath('draft/report.html'), true); + assert.equal(isMindSpacePageWriteRelativePath('private/notes.html'), false); + assert.equal(isMindSpacePageWriteRelativePath('public/data.json'), false); +}); diff --git a/mindspace-workspace-sync.mjs b/mindspace-workspace-sync.mjs index 9b1c5b6..1fdf055 100644 --- a/mindspace-workspace-sync.mjs +++ b/mindspace-workspace-sync.mjs @@ -11,6 +11,7 @@ import { resolveAssetWorkspaceRelativePath } from './mindspace-workspace-path.mj import { assetInternals } from './mindspace-assets.mjs'; import { runBasicFileScan } from './mindspace-scan.mjs'; import { buildWorkspaceStorageKey } from './workspace-storage.mjs'; +import { assertPageWriteQuota } from './mindspace-space-quota.mjs'; const SKIP_FILENAMES = new Set(['index.html', '.tkmindhints', '.goosehints', '.ls_output']); const SKIP_ZONE_DIR_NAMES = new Set([ @@ -186,10 +187,13 @@ export function createWorkspaceAssetSync({ if (!space || space.status !== 'active') { throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' }); } - const available = - asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes); - if (available < buffer.length) { - throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' }); + try { + assertPageWriteQuota(space, buffer.length); + } catch (error) { + throw Object.assign(new Error(error.message), { + code: error.code ?? 'quota_exceeded', + details: error.details, + }); } const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename); @@ -346,10 +350,15 @@ export function createWorkspaceAssetSync({ }; } const sizeDelta = buffer.length - asNumber(currentAsset.size_bytes); - const available = - asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes); - if (sizeDelta > 0 && available < sizeDelta) { - throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' }); + if (sizeDelta > 0) { + try { + assertPageWriteQuota(space, sizeDelta); + } catch (error) { + throw Object.assign(new Error(error.message), { + code: error.code ?? 'quota_exceeded', + details: error.details, + }); + } } const versionId = currentAsset.current_version_id; if (!versionId) { diff --git a/mindspace-workspace-tool-service.mjs b/mindspace-workspace-tool-service.mjs index c451b26..6bd1789 100644 --- a/mindspace-workspace-tool-service.mjs +++ b/mindspace-workspace-tool-service.mjs @@ -13,6 +13,10 @@ import { import { UPLOAD_ZONE_CODES, } from './user-space.mjs'; +import { + assertPageWriteQuotaForUser, + isMindSpacePageWriteRelativePath, +} from './mindspace-space-quota.mjs'; function workspaceToolError(message, code) { return Object.assign(new Error(message), { code }); @@ -330,6 +334,9 @@ export function createMindSpaceWorkspaceToolService({ maxBinaryWriteBytes = 8 * 1024 * 1024, maxGeneratedBinaryBytes = 40 * 1024 * 1024, + assertPageWriteQuotaForUserFn = + assertPageWriteQuotaForUser, + getQuotaPool = null, } = {}) { if (!h5Root) { throw new Error( @@ -395,6 +402,23 @@ export function createMindSpaceWorkspaceToolService({ }; }; + const ensurePageWriteAllowed = async ({ + userId, + relativePath, + requiredBytes, + }) => { + if ( + !getQuotaPool || + typeof assertPageWriteQuotaForUserFn !== 'function' || + !isMindSpacePageWriteRelativePath(relativePath) + ) { + return; + } + const pool = getQuotaPool(); + if (!pool) return; + await assertPageWriteQuotaForUserFn(pool, userId, requiredBytes); + }; + const registerWrite = async ({ scope, relativePath, @@ -523,6 +547,11 @@ export function createMindSpaceWorkspaceToolService({ content, { relativePath }, ); + await ensurePageWriteAllowed({ + userId: scope.userId, + relativePath, + requiredBytes: sizeBytes, + }); const target = await resolveWritableWorkspacePath( scope.workspaceRoot, @@ -620,6 +649,12 @@ export function createMindSpaceWorkspaceToolService({ updated, { relativePath }, ); + const originalSizeBytes = mimeSafeByteLength(original); + await ensurePageWriteAllowed({ + userId: scope.userId, + relativePath, + requiredBytes: Math.max(0, sizeBytes - originalSizeBytes), + }); await fs.writeFile(target, updated, 'utf8'); const registration = await registerWrite({ scope, diff --git a/page-data-collect-skill.test.mjs b/page-data-collect-skill.test.mjs index 29b9d6f..03ddc62 100644 --- a/page-data-collect-skill.test.mjs +++ b/page-data-collect-skill.test.mjs @@ -18,3 +18,16 @@ test('page-data-collect skill pins deletion to the public client API', () => { assert.match(skillText, /禁止发明 `softDeleteRows`/); assert.match(skillText, /表必须包含 `deleted_at TIMESTAMPTZ`/); }); + +test('page-data-collect skill documents page quota grace and delivery URLs', () => { + assert.match(skillText, /本次写完、下次再拦/); + assert.match(skillText, /127\.0\.0\.1:8081\/MindSpace/); + assert.match(skillText, /禁止.*本地服务/); + assert.match(skillText, /pageId.*UUID/); +}); + +test('page-data-collect skill requires independent table per delivery task', () => { + assert.match(skillText, /每个独立交付任务必须独立建表/); + assert.match(skillText, /禁止复用已有表/); + assert.match(skillText, /禁止.*为新的独立页面复用已有 dataset/); +}); diff --git a/scripts/aider-goose-wrapper.sh b/scripts/aider-goose-wrapper.sh new file mode 100755 index 0000000..ee4bfbd --- /dev/null +++ b/scripts/aider-goose-wrapper.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="/Users/john/Project/Memind" +ENV_SCRIPT="${ROOT_DIR}/scripts/executor-env.mjs" +AIDER_BIN_DEFAULT="/Users/john/.local/bin/aider" +AIDER_BIN="${AIDER_CLI_BIN:-${AIDER_BIN:-${AIDER_BIN_DEFAULT}}}" +NODE_BIN="${NODE_BIN:-$(command -v node || true)}" + +if [[ -z "${NODE_BIN}" ]]; then + echo "node not found in PATH" >&2 + exit 1 +fi + +if [[ ! -x "${AIDER_BIN}" ]]; then + echo "Aider CLI not executable: ${AIDER_BIN}" >&2 + exit 1 +fi + +eval "$("${NODE_BIN}" "${ENV_SCRIPT}" aider --shell)" + +exec "${AIDER_BIN}" "$@" diff --git a/scripts/check-tool-runtime.mjs b/scripts/check-tool-runtime.mjs index 4a5b7ba..df0cfc2 100644 --- a/scripts/check-tool-runtime.mjs +++ b/scripts/check-tool-runtime.mjs @@ -78,17 +78,22 @@ try { codeHasCodeTools: grantedTools.includes('aider') || grantedTools.includes('openhands'), })); + const roleCodeToolsDefault = ['1', 'true', 'yes', 'on'].includes( + String(process.env.MEMIND_ROLE_CODE_TOOLS_DEFAULT ?? '').trim().toLowerCase(), + ); + const expectedRoleCodeTools = roleCodeToolsDefault; const ok = Boolean( - roleDefaults.aider === false && - roleDefaults.openhands === false && + roleDefaults.aider === expectedRoleCodeTools && + roleDefaults.openhands === expectedRoleCodeTools && sampledUsers.every((user) => user.chatHasCodeTools === false) && - sampledUsers.every((user) => user.codeHasCodeTools === true), + (roleCodeToolsDefault || sampledUsers.every((user) => user.codeHasCodeTools === true)), ); console.log(JSON.stringify({ ok, checkedAt: new Date().toISOString(), roleDefaults, + roleCodeToolsDefault, whitelistUserCount: grantsByUser.size, sampledUsers, runtimePolicy: { diff --git a/scripts/fix-openhands-local-link.mjs b/scripts/fix-openhands-local-link.mjs new file mode 100644 index 0000000..9673a5b --- /dev/null +++ b/scripts/fix-openhands-local-link.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * 本机代码工具能力修复:role 默认 aider/openhands + 可选 canary 用户白名单。 + * 用法: MEMIND_ENV_FILE=.env node scripts/fix-openhands-local-link.mjs + * + * MEMIND_ROLE_CODE_TOOLS_DEFAULT=1 → role=user 默认开启 aider/openhands(本地开发) + * 未设置或为 0 → role=user 默认关闭(生产基线) + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createDbPool } from '../db.mjs'; +import { applyRoleSecurityBaseline } from '../security-baseline.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const CANARY_USER_ID = process.env.LOCAL_OPENHANDS_CANARY_USER_ID + ?? '32035858-9a20-425b-89da-c118ef0779aa'; +const CODE_TOOLS = ['aider', 'openhands']; + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx < 0) continue; + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +function roleCodeToolsDefaultEnabled() { + return ['1', 'true', 'yes', 'on'].includes( + String(process.env.MEMIND_ROLE_CODE_TOOLS_DEFAULT ?? '').trim().toLowerCase(), + ); +} + +loadEnvFile(process.env.MEMIND_ENV_FILE || path.join(root, '.env')); +loadEnvFile(path.join(root, '.env.local')); + +async function readRoleDefaults(pool) { + const [rows] = await pool.query( + `SELECT capability_key, allowed + FROM h5_capability_grants + WHERE subject_type = 'role' + AND subject_id = 'user' + AND capability_key IN ('aider', 'openhands') + ORDER BY capability_key`, + ); + return Object.fromEntries(rows.map((row) => [row.capability_key, Boolean(row.allowed)])); +} + +async function readUserGrants(pool, userId) { + const [rows] = await pool.query( + `SELECT capability_key, allowed + FROM h5_capability_grants + WHERE subject_type = 'user' + AND subject_id = ? + AND capability_key IN ('aider', 'openhands') + ORDER BY capability_key`, + [userId], + ); + return Object.fromEntries(rows.map((row) => [row.capability_key, Boolean(row.allowed)])); +} + +async function grantRoleTools(pool, role, tools, allowed) { + const now = Date.now(); + for (const key of tools) { + await pool.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('role', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`, + [role, key, allowed ? 1 : 0, now], + ); + } +} + +async function grantUserTools(pool, userId, tools) { + const now = Date.now(); + for (const key of tools) { + await pool.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('user', ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`, + [userId, key, now], + ); + } +} + +const roleCodeToolsDefault = roleCodeToolsDefaultEnabled(); +const pool = createDbPool(); +try { + const beforeRole = await readRoleDefaults(pool); + const beforeUser = await readUserGrants(pool, CANARY_USER_ID); + + if (roleCodeToolsDefault) { + await grantRoleTools(pool, 'user', CODE_TOOLS, true); + } else { + await applyRoleSecurityBaseline(pool, 'user'); + await grantUserTools(pool, CANARY_USER_ID, CODE_TOOLS); + } + + const afterRole = await readRoleDefaults(pool); + const afterUser = await readUserGrants(pool, CANARY_USER_ID); + const expectedRole = roleCodeToolsDefault; + + console.log(JSON.stringify({ + ok: afterRole.aider === expectedRole + && afterRole.openhands === expectedRole + && (!roleCodeToolsDefault || CODE_TOOLS.every((key) => afterRole[key] === true)), + roleCodeToolsDefault, + canaryUserId: CANARY_USER_ID, + before: { role: beforeRole, user: beforeUser }, + after: { role: afterRole, user: afterUser }, + }, null, 2)); +} finally { + await pool.end(); +} diff --git a/skills/page-data-collect/SKILL.md b/skills/page-data-collect/SKILL.md index 74954bf..75b30a7 100644 --- a/skills/page-data-collect/SKILL.md +++ b/skills/page-data-collect/SKILL.md @@ -130,6 +130,16 @@ load_skill → page-data-collect ### 2. 数据层:建表 + 注册 dataset +**每个独立交付任务必须独立建表、独立 dataset,禁止复用已有表。** + +| 场景 | 正确做法 | +|------|----------| +| 用户要做一个**新页面 / 新功能**(即使主题相似,如第二个日记本、另一个问卷) | 新建专用表 + 新 dataset 名,例如 `safe_diary_entries`、`children_hobby_survey_20260729` | +| 用户明确说「在**这个已有页面**里加一块表单」 | 才可复用该页已 bind 的 dataset | +| Agent 在 registry 里看到同名/同主题旧表 | **不得**直接拿来给新 HTML 用;必须新建 | + +命名建议:`{页面slug}_entries` / `{页面slug}_responses`,表名与 dataset 名一致。两个页面即使业务相似(日记、台账、问卷),也必须是**两套** `{table, dataset, policy}`,避免字段约束、口令策略、软删除配置互相污染。 + 用 `private_data_execute` 建表(示例): ```sql @@ -164,6 +174,25 @@ CREATE TABLE IF NOT EXISTS survey_responses ( - PostgreSQL 连接错误应原样报告,不得把 `/tmp/.s.PGSQL.*`、`ECONNREFUSED` 或 `permission denied` 解释成“稍后会自动恢复”。 - dataset 配置了 `soft_delete` 时,表必须包含 `deleted_at TIMESTAMPTZ`;配置了 `own_rows` 时,表必须包含 policy 指定的所有者字段。 +### 用户空间配额(页面写入 grace 策略) + +MindSpace **页面 HTML**(`public/*.html`、`draft/*.html`)写入采用 **「本次写完、下次再拦」**: + +| 状态 | 平台行为 | +|------|----------| +| 尚未超配额,但剩余空间 < 本次页面大小 | ✅ **允许写完**(grace write;本次 bind 必须继续完成) | +| 已经超配额(used ≥ quota) | ❌ **直接拦截** `write_file` / `edit_file` / bind / 发布 | +| 空间充足 | ✅ 正常写入 | + +**Agent 必须遵守:** + +- grace 只保证**当次**页面 HTML 能落盘并完成 bind;**不是**可以跳过 bind 或只写 policy 文件。 +- bind 失败(含 `quota_exceeded` 且已超配额、`dataset_schema_mismatch`、PG 连接错误)时,**禁止**向用户交付「已就绪」链接;必须如实报告失败原因。 +- 用户空间已满时,提示其在 MindSpace「我的空间」清理旧页面/资产,或购买/扩容后再继续写新页面。 +- grace **不覆盖**图片/附件等非页面 HTML 资产上传;那些仍按原有配额硬拦截。 + +**禁止**只写 `.mindspace/page-data-policies/.json` 或使用非 UUID 的 `pageId` 冒充 bind 成功;policy 的 `pageId` 必须来自 `private_data_bind_workspace_page` 返回的真实 UUID,且数据库中存在对应 `h5_page_records`。 + ### 3. 页面层:写 HTML - 用 `write_file` / `edit_file` 写入或更新 `public/*.html` @@ -234,9 +263,18 @@ await client.deleteRow('dataset_name', rowId); } ``` -返回 `pageId` 与 **`workspaceUrl`**(`/MindSpace/<用户ID>/public/xxx.html`)。 +返回 `pageId`(UUID)与 **`workspaceUrl` / `deliveryUrl`**(`/MindSpace/<用户ID>/public/xxx.html`)。 -**交付时必须优先给用户 workspaceUrl**;`/u/用户名/pages/...` 仅作补充。bind 会同步工作区 HTML 到发布快照,但禁止先发布占位内容再补文件。 +**交付时必须优先给用户真实可点击 URL**: + +| 环境 | 链接前缀示例 | +|------|-------------| +| **本地开发** | `http://127.0.0.1:8081/MindSpace/<用户ID>/public/xxx.html`(Page Data API 走 Portal 8081;5173 仅 UI 预览) | +| **生产** | `https://m.tkmind.cn/MindSpace/<用户ID>/public/xxx.html` | + +- **禁止**交付占位 host(如 `http://本地服务/...`);聊天展示层不得把 `127.0.0.1:8081` 替换成不可点击文字。 +- `/u/用户名/pages/...` 仅作补充,用户可见主链接必须是 MindSpace `/public/` 路径。 +- bind 会同步工作区 HTML 到发布快照,但禁止先发布占位内容再补文件。 **dataset 名称必须与 HTML 一致**:`private_data_register_dataset` 的 `name`、`private_data_bind_workspace_page` 的 `datasets` 键名、以及 HTML 里 `insertRow('...')` / `listRows('...')` 的字符串必须完全相同(例如都用 `tkmind_exp_survey`)。若不一致,提交会报「dataset 未授权 insert/read」。 @@ -293,17 +331,28 @@ await client.deleteRow('dataset_name', rowId); 11. **禁止**未配置 `private_data_set_page_policy` 就让页面调用公开 API 12. **禁止**先发布占位页(如 `

问卷页面

`)再让用户访问 `/u/.../pages/...` 13. **禁止**在 Agent 生成的 HTML/JavaScript 中使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据或做 API fallback;所有需持久化的数据必须进入当前用户专属 PostgreSQL schema,禁止 SQLite、静态 JSON/JS 文件和内存 fallback +14. **禁止**在空间已满(已超配额)时继续 `write_file` / bind 新页面;必须先提示清理或扩容 +15. **禁止**bind 未完成或 `pageId` 非 UUID 时声称 Page Data 页面已就绪;控制台出现 `pageId 未配置` 说明 bind/发布链路未闭环 +16. **禁止**为新的独立页面复用已有 dataset / 表(例如第二个日记页继续用 `diary_entries`);除非用户明确要求改同一页面上的表单 ## 交付前自检 0. 已选定分支 A~E,方案摘要已确认;`password` 页口令 ≥8 位且 bind 已传入 -1. `__page_data_datasets` 中存在对应 dataset -2. `.mindspace/page-data-policies/.json` 已写入 -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)` +1. `private_data_register_dataset` 已注册;PostgreSQL 表字段与 policy 一致(含 `deleted_at` 等) +2. `private_data_bind_workspace_page` **已成功**,返回 UUID 形式 `pageId` 与 `deliveryUrl` +3. `.mindspace/page-data-policies/.json` 已写入(文件名必须是 UUID,不是 slug) +4. 通过 Portal 打开 `deliveryUrl` 时,页面源码含 `__MINDSPACE_PAGE_DATA__` +5. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId +6. HTML **不含**硬编码 `127.0.0.1:` 作为 API 基址;不含 `/api/survey/`、`PLACEHOLDER_PAGE_ID` +7. 本地交付链接使用 `http://127.0.0.1:8081/MindSpace/...`;生产使用 `https://m.tkmind.cn/MindSpace/...`;禁止 `http://本地服务/...` +8. 向用户说明:访客如何提交、管理员如何用口令查看记录 +9. HTML 未调用 `softDeleteRows` / `deleteRows` 等客户端不存在的方法;删除使用 `deleteRow(dataset, rowId)` +10. 若 bind 报 `quota_exceeded` 且 `overQuota: true`,停止交付并提示清理空间;grace 仅适用于「当次写完、尚未超配额」场景 ## 回复格式 -除数据能力外,优先返回 **workspaceUrl** 的 Markdown 链接 `[标题](workspaceUrl)`,并简要说明后台入口与口令(如有)。 +除数据能力外,优先返回 **deliveryUrl / workspaceUrl** 的 Markdown 链接 `[标题](完整URL)`,并简要说明后台入口与口令(如有)。 + +- 本地:`http://127.0.0.1:8081/MindSpace/<用户ID>/public/xxx.html` +- 生产:`https://m.tkmind.cn/MindSpace/<用户ID>/public/xxx.html` +- 必须附带 bind 返回的 `pageId`(UUID),便于用户在「页面数据」面板排查 diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index 2671058..5e14b77 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -29,6 +29,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 12. **微信分享必须使用 MindSpace 路径**:该路由会在服务端注入 `og:site_name=TKMind 智趣`、封面缩略图(`*.thumbnail.png`)与微信 JS-SDK 分享桥;禁止交付 `/u/用户名/pages/...` 作为用户可见链接 13. **只要页面接收用户输入并要求以后查看、汇总、统计、修改或删除,就不是纯静态页**。禁止使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据;必须立即 `load_skill` → `page-data-collect`,通过 Page Data API 写入当前用户专属 PostgreSQL schema。禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。 14. 当编排提示为“图片策略:强制生成”或用户明确要求新图片、背景图、主图、封面、插画、照片时,必须先 `load_skill` → `image-generation`,由该技能补齐提示词并调用 `sandbox-fs__generate_image`;不得要求用户自己提供专业生图参数,不得用 SVG、CSS 绘图、旧素材或网页搜索图片冒充本次生成结果。 +15. **页面 HTML 空间配额**:尚未超配额时,平台允许当次页面写完(grace write);**已超配额**后 `write_file` / `edit_file` 会被直接拦截。空间已满时提示用户清理 MindSpace 旧页面/资产后再继续。 详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。 @@ -74,7 +75,10 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 - **必须**使用 `[页面标题](完整URL)`,不要只给裸 URL 或「点这里」 - 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致) -- 域名严格按本节模板拼接(`https://goo.tkmind.cn/MindSpace/<用户ID>/public/...`);拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名 +- 域名严格按环境拼接,**禁止**使用 `http://本地服务/...` 等占位 host: + - **本地开发**:`http://127.0.0.1:8081/MindSpace/<用户ID>/public/...`(MindSpace 公开页 + Page Data API) + - **生产**:`https://m.tkmind.cn/MindSpace/<用户ID>/public/...` 或环境配置的 `H5_PUBLIC_BASE_URL` +- 拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名 - 标题用页面真实主题名 - 可同时给出相对路径(如 `public/malaysia-travel-guide.html`) - 说明:保存即生效,无需重启