From b1577a16e98a01440af9c0d6e18a27d1888c8ef1 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 26 Jul 2026 14:32:01 +0800 Subject: [PATCH] fix: harden release gate and page delivery --- PRODUCTION_RELEASE_RULES.md | 4 +- agent-run-routes.mjs | 12 + agent-run-routes.test.mjs | 35 + deepseek-no-think-proxy.mjs | 2 + docs/production-release-guardian.md | 229 +++- docs/release-canary-103.md | 13 + docs/release-gate-automation.md | 171 +++ excel-analysis-engine.mjs | 54 +- excel-report-html.mjs | 5 +- release-gate/artifact.mjs | 159 +++ release-gate/artifact.test.mjs | 104 ++ release-gate/canary-routing.mjs | 74 ++ release-gate/canary-routing.test.mjs | 44 + release-gate/catalog.mjs | 157 +++ release-gate/catalog.test.mjs | 32 + release-gate/changed-scope.test.mjs | 25 + release-gate/coverage.mjs | 1167 +++++++++++++++++ release-gate/document-offline.test.mjs | 25 + release-gate/excel-continuity.test.mjs | 176 +++ release-gate/image-page-replay.test.mjs | 159 +++ release-gate/local-stack.mjs | 447 +++++++ release-gate/local-stack.test.mjs | 43 + release-gate/page-data-evolution.test.mjs | 132 ++ release-gate/public-url.mjs | 102 ++ release-gate/public-url.test.mjs | 38 + release-gate/regression-corpus.mjs | 154 +++ release-gate/regression-corpus.test.mjs | 65 + release-gate/release-runtime-safety.test.mjs | 43 + release-gate/release-script.test.mjs | 76 ++ release-gate/report.mjs | 275 ++++ release-gate/report.test.mjs | 112 ++ release-gate/runner.mjs | 257 ++++ release-gate/runner.test.mjs | 33 + .../runtime-worker-entrypoint.test.mjs | 9 + release-gate/safety.mjs | 80 ++ release-gate/safety.test.mjs | 46 + scenarios/long-content-rich-page.json | 27 + .../agent-run/PRC-REL-001.json | 16 + .../manifests/active.json | 6 + scripts/build-portal-runtime.mjs | 72 +- scripts/check-release-runtime-safety.mjs | 50 + scripts/dev-core.mjs | 2 + scripts/git-porcelain-paths.mjs | 17 + scripts/release-portal-runtime-prod.sh | 103 +- scripts/run-release-gate-artifact-repro.mjs | 33 + scripts/run-release-gate-auth-scenarios.mjs | 29 + scripts/run-release-gate-browser-smoke.mjs | 222 ++++ scripts/run-release-gate-local-smoke.mjs | 231 ++++ .../run-release-gate-page-data-scenarios.mjs | 124 ++ scripts/run-release-gate-page-scenarios.mjs | 90 ++ scripts/run-release-gate-public-url-scan.mjs | 20 + .../run-release-gate-regression-corpus.mjs | 34 + .../run-release-gate-runtime-cold-start.mjs | 60 + .../run-release-gate-runtime-container.mjs | 116 ++ scripts/run-release-gate-runtime-upgrade.mjs | 135 ++ scripts/run-release-gate.mjs | 32 + scripts/verify-release-gate-report.mjs | 70 + server/portal-agent-services-bootstrap.mjs | 30 +- .../portal-agent-services-bootstrap.test.mjs | 14 + user-publish.mjs | 4 +- user-publish.test.mjs | 10 + 61 files changed, 6017 insertions(+), 89 deletions(-) create mode 100644 docs/release-canary-103.md create mode 100644 docs/release-gate-automation.md create mode 100644 release-gate/artifact.mjs create mode 100644 release-gate/artifact.test.mjs create mode 100644 release-gate/canary-routing.mjs create mode 100644 release-gate/canary-routing.test.mjs create mode 100644 release-gate/catalog.mjs create mode 100644 release-gate/catalog.test.mjs create mode 100644 release-gate/changed-scope.test.mjs create mode 100644 release-gate/coverage.mjs create mode 100644 release-gate/document-offline.test.mjs create mode 100644 release-gate/excel-continuity.test.mjs create mode 100644 release-gate/image-page-replay.test.mjs create mode 100644 release-gate/local-stack.mjs create mode 100644 release-gate/local-stack.test.mjs create mode 100644 release-gate/page-data-evolution.test.mjs create mode 100644 release-gate/public-url.mjs create mode 100644 release-gate/public-url.test.mjs create mode 100644 release-gate/regression-corpus.mjs create mode 100644 release-gate/regression-corpus.test.mjs create mode 100644 release-gate/release-runtime-safety.test.mjs create mode 100644 release-gate/release-script.test.mjs create mode 100644 release-gate/report.mjs create mode 100644 release-gate/report.test.mjs create mode 100644 release-gate/runner.mjs create mode 100644 release-gate/runner.test.mjs create mode 100644 release-gate/runtime-worker-entrypoint.test.mjs create mode 100644 release-gate/safety.mjs create mode 100644 release-gate/safety.test.mjs create mode 100644 scenarios/long-content-rich-page.json create mode 100644 scenarios/production-regressions/agent-run/PRC-REL-001.json create mode 100644 scenarios/production-regressions/manifests/active.json create mode 100644 scripts/check-release-runtime-safety.mjs create mode 100644 scripts/git-porcelain-paths.mjs create mode 100644 scripts/run-release-gate-artifact-repro.mjs create mode 100644 scripts/run-release-gate-auth-scenarios.mjs create mode 100644 scripts/run-release-gate-browser-smoke.mjs create mode 100644 scripts/run-release-gate-local-smoke.mjs create mode 100644 scripts/run-release-gate-page-data-scenarios.mjs create mode 100644 scripts/run-release-gate-page-scenarios.mjs create mode 100644 scripts/run-release-gate-public-url-scan.mjs create mode 100644 scripts/run-release-gate-regression-corpus.mjs create mode 100644 scripts/run-release-gate-runtime-cold-start.mjs create mode 100644 scripts/run-release-gate-runtime-container.mjs create mode 100644 scripts/run-release-gate-runtime-upgrade.mjs create mode 100644 scripts/run-release-gate.mjs create mode 100644 scripts/verify-release-gate-report.mjs diff --git a/PRODUCTION_RELEASE_RULES.md b/PRODUCTION_RELEASE_RULES.md index 50e011f..fcf6678 100644 --- a/PRODUCTION_RELEASE_RULES.md +++ b/PRODUCTION_RELEASE_RULES.md @@ -8,7 +8,7 @@ 2. **禁止 SSH 登录 `105` 直接修改业务代码**(含服务号菜单脚本 `scripts/wechat-mp-menu.mjs`)。105 上文件是部署产物;变更必须:本地 `test-memind` 修改 → Git commit → 正式发布 → 必要时在目标环境执行 API 同步。详见 [docs/105-server-operations.md](docs/105-server-operations.md)。 2. **Portal 生产必须是无源码 runtime 模式**:构建只发生在本机 Mac,产物是 `.runtime/portal/`;`103` 只接收 runtime artifact、继承持久目录、启动服务,**禁止**在 `103` 上 `npm install`、`npm run build` 或保留可运行源码树。 2. `MindSpace` 独立服务同样必须走单独 runtime artifact:本地 `node scripts/build-mindspace-service-runtime.mjs` -> `bash scripts/release-mindspace-service-prod.sh` -> 上传 `103` -> 备份 `/Users/john/MindSpace` 与共享 `Memind/.env` -> 原子切换到 `/Users/john/MindSpace` -> 健康检查 `127.0.0.1:8082/health` 与 `/mindspace/v1/contract`;禁止手工 SSH 改线上 `/Users/john/MindSpace` 源码。103 当前拓扑见 [docs/103-runtime-topology.md](docs/103-runtime-topology.md)。 -3. Portal 生产发布唯一合法路径是:本地已提交代码 -> 本机 `node scripts/build-portal-runtime.mjs` -> `bash scripts/release-portal-runtime-prod.sh` -> 上传 `103` -> 全量备份 + 持久目录备份 -> 原子切换 live 目录 -> 重启 Portal -> 健康检查。 +3. Portal 生产发布必须先构建候选 runtime 并通过用户级灰度入口;当前 `scripts/release-portal-runtime-prod.sh` 是整包替换脚本,在用户级灰度入口未上线、未验证前禁止调用。灰度只能命中明确的不可变用户身份,未命中请求必须继续走稳定版本,禁止直接全量切换。 4. `scripts/release-prod.sh`(源码包发布)已停用,不得再用于 Portal;`rsync_to_server.sh` 与任何面向 `105` 的直接同步脚本也只保留为禁用提示。 5. Portal 发布包不得携带运行态资产;`.env`、`data/`、`users/`、`.tailscale/`、`public/plaza-covers/`、`logs/` 只能从线上现有 live 目录继承。`/Users/john/MindSpace` 是独立 MindSpace Service 的生产根目录,不属于 Portal runtime 包;`/Users/john/Project/Memind/MindSpace` 只允许作为旧链路兼容/存量目录处理,不得再被写成 MindSpace Service 的当前根目录。 6. runtime artifact 必须包含 `server.mjs` 与 `mindspace-sandbox-mcp.mjs`(`sandbox-fs` 扩展依赖的独立子进程入口)。 @@ -21,4 +21,4 @@ 13. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。 14. 每次生产发布必须生成与完整 `main` commit 和 runtime artifact SHA256 绑定的 Gate report;结果必须满足 `failed=0`、`skipped=0`、`cleanup_failed=0`。 15. 确实不受本次变更影响的场景只能按生产发布守门员记录为经过审核的 `not_applicable`;无法确认影响时必须执行,禁止用 `--skip-tests`、环境变量或口头说明绕过。 -16. 当前发布脚本在完整接入生产发布守门员之前,不得被视为满足新的生产发布条件。 +16. 当前发布脚本已接入 Gate report 上传前硬校验,但场景自动化尚未覆盖 187 项;任何 `unknown`、未执行或未实现项都会阻断,因此在完整报告通过前仍不得发布。即使 Gate 全绿,也不能把整包替换脚本当作灰度发布入口。 diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 509860d..3d65624 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { normalizeAgentRunToolMode } from './agent-run-gateway.mjs'; import { AIDER_DEVELOPMENT_SKILL_NAME, @@ -166,6 +169,15 @@ export function createPostAgentRunsHandler({ return async function postAgentRuns(request, response) { try { + const releaseDrainFile = process.env.MEMIND_RELEASE_DRAIN_FILE + || path.join(process.cwd(), '.release-drain'); + if (fs.existsSync(releaseDrainFile)) { + response.status(503).json({ + message: '系统正在进行发布排水,请稍后重试', + code: 'RELEASE_DRAIN_ACTIVE', + }); + return; + } const sessionId = String(request.body?.session_id ?? '').trim() || null; const requestId = String(request.body?.request_id ?? '').trim(); let userMessage = request.body?.user_message ?? null; diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index d82b164..86e9523 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -1,4 +1,7 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import { createAgentRunEventsHandler, @@ -101,6 +104,38 @@ test('POST /agent/runs creates a run and returns 202', async () => { ]); }); +test('POST /agent/runs fails closed while the release drain marker exists', async () => { + const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-release-drain-')); + const marker = path.join(markerDir, 'drain'); + const previous = process.env.MEMIND_RELEASE_DRAIN_FILE; + process.env.MEMIND_RELEASE_DRAIN_FILE = marker; + await fs.writeFile(marker, 'release-test\n'); + try { + const handler = createPostAgentRunsHandler({ + userAuth: {}, + agentRunGateway: { + async createRun() { + assert.fail('release drain must reject before creating a run'); + }, + }, + }); + const res = createResponseRecorder(); + await handler( + { + currentUser: { id: 'user-1' }, + body: { request_id: 'req-drain', user_message: { role: 'user', content: [] } }, + }, + res, + ); + assert.equal(res.statusCode, 503); + assert.equal(res.body.code, 'RELEASE_DRAIN_ACTIVE'); + } finally { + if (previous === undefined) delete process.env.MEMIND_RELEASE_DRAIN_FILE; + else process.env.MEMIND_RELEASE_DRAIN_FILE = previous; + await fs.rm(markerDir, { recursive: true, force: true }); + } +}); + test('selected Aider development skill forces code mode, task type, and Aider executor', async () => { const userMessage = { role: 'user', diff --git a/deepseek-no-think-proxy.mjs b/deepseek-no-think-proxy.mjs index dc6d265..5db1b28 100644 --- a/deepseek-no-think-proxy.mjs +++ b/deepseek-no-think-proxy.mjs @@ -392,6 +392,8 @@ export function startDeepseekNoThinkProxy({ } const isMain = Boolean( + process.env.MEMIND_DEEPSEEK_PROXY_ENTRYPOINT === '1' + && process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]), ); diff --git a/docs/production-release-guardian.md b/docs/production-release-guardian.md index fb6ea57..9637812 100644 --- a/docs/production-release-guardian.md +++ b/docs/production-release-guardian.md @@ -2,7 +2,7 @@ > 适用目标:生产 `103` Portal runtime 及其共享链路。 > 规则状态:已确认为生产发布的强制验收规范。 -> 当前实施状态:场景和证据规范已建立;自动场景执行器、机器报告和发布脚本硬阻断仍需按本文落地。在三者完成并验证前,不得宣称生产发布已经通过本守门员。 +> 当前实施状态:187 场景机器目录、fail-closed 执行器、artifact 哈希、机器报告校验和发布脚本上传前硬阻断已经落地;业务场景自动化仍在分批补齐。任何未实现项保持 `unknown` 并阻断发布,在 187 项全部通过或合法豁免前,不得宣称生产发布已经通过本守门员。 ## 1. 目标 @@ -53,15 +53,18 @@ - `REL-*` 全部发布、升级、备份和回滚场景。 - `AUTH-01`、`AUTH-04`、`AUTH-05`、`AUTH-06`。 -- `CHAT-01`、`CHAT-02`、`CHAT-06`、`CHAT-07`、`CHAT-08`、`CHAT-09`。 -- `MEM-08` 用户记忆隔离。 +- `CHAT-01`、`CHAT-02`、`CHAT-06`、`CHAT-07`、`CHAT-08`、`CHAT-09`、`CHAT-11`、`CHAT-12`、`CHAT-13`、`CHAT-15`。 +- `MEM-08`、`MEM-13`、`MEM-14`、`MEM-15`、`MEM-16`。 - `FILE-07` 附件跨用户隔离。 -- `PAGE-08`、`PAGE-09`、`PAGE-10`。 +- `PAGE-08`、`PAGE-09`、`PAGE-10`、`PAGE-12`。 - `DATA-06`、`DATA-10`、`DATA-12`。 +- `AGENT-10`、`AGENT-11`。 +- `WX-09` 至 `WX-13`。 - `BILL-02`、`BILL-03`、`BILL-04`。 - `MS-01`、`MS-03`、`MS-04`。 - `COMP-01` 至 `COMP-04` 既有生产数据兼容场景。 -- `COMP-08` 基线并发和资源泄漏场景。 +- `CFG-08` 模型配置兼容场景。 +- `COMP-08`、`COMP-09`。 ### 3.3 `not_applicable` 的判定 @@ -170,6 +173,8 @@ evidence: - API 响应 - memory query trace - database assertion +production_regression_refs: + - PRC-MEM-001 ``` LLM 场景不得只断言固定文案,应优先断言: @@ -183,9 +188,9 @@ LLM 场景不得只断言固定文案,应优先断言: ## 7. 场景目录 -以下共 19 个场景包、160 个场景族。每个场景族可以在场景文件中继续扩展参数化 case,但不得减少本文要求。 +以下共 19 个场景包、187 个场景族。每个场景族可以在场景文件中继续扩展参数化 case,但不得减少本文要求。 -### 7.1 发布与运行时(10) +### 7.1 发布与运行时(11) - **REL-01**:候选来源必须是干净、非 detached 的完整 `main`;记录完整 SHA。 - **REL-02**:候选不落后 `origin/main`,已进入远端且 CI 通过,没有未合并关键变更。 @@ -197,6 +202,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **REL-08**:9 个 goosed、worker、Portal 和隧道全部指向并识别新 runtime。 - **REL-09**:注入启动或健康检查失败后,完整恢复旧 live 目录和旧 release。 - **REL-10**:活动任务排空、备份一致、上传中断和切换前失败均不影响当前版本。 +- **REL-11**:启用扩展及 runtime 的依赖闭包完整;每个打包的 MCP 模块都存在,并能在生产同构容器内实际启动。 ### 7.2 登录、账号和权限(8) @@ -209,7 +215,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **AUTH-07**:访问策略 `off`、`enforce` 和 kill switch 三种模式符合预期。 - **AUTH-08**:路径穿越、XSS、SQL 注入和敏感配置泄露防护有效。 -### 7.3 聊天、会话和流式恢复(10) +### 7.3 聊天、会话和流式恢复(15) - **CHAT-01**:普通问候进入 Direct Chat 并返回用户可见回复。 - **CHAT-02**:页面、文件和工具任务进入 Agent 路径。 @@ -221,8 +227,13 @@ LLM 场景不得只断言固定文案,应优先断言: - **CHAT-08**:Portal replay ID 不直接作为 Goose `Last-Event-ID`。 - **CHAT-09**:SSE 不可用时,轮询能够恢复 Agent 最终状态和消息。 - **CHAT-10**:重复请求、失败重试、取消和损坏 tool history 修复正确。 +- **CHAT-11**:已有活动请求时再次发送消息,排队、取消或新建会话的行为明确,不暴露原始技术错误,也不丢失任一请求。 +- **CHAT-12**:兼容历史消息契约,包括 `userVisible`/`agentVisible` 字段和 legacy、非 UUID request ID。 +- **CHAT-13**:只有助手消息、Finish、会话快照和规范化消息全部持久化且顺序、时间一致后,任务才允许标记成功。 +- **CHAT-14**:用户纠正“不是这个文件/对象”时,本轮纠正覆盖过期上下文,真实任务不会被 routing、skill 或内部提示覆盖。 +- **CHAT-15**:快照中的每条用户可见文本都同步到规范化消息表,单字回复、Finish 边界和恢复流程均不得静默丢消息。 -### 7.4 用户记忆与记忆召回(12) +### 7.4 用户记忆与记忆召回(16) - **MEM-01**:用户明确要求“记住”后成功保存偏好。 - **MEM-02**:新会话能够准确召回已保存偏好。 @@ -236,8 +247,12 @@ LLM 场景不得只断言固定文案,应优先断言: - **MEM-10**:候选记忆、确认、晋升、过期和作用域生命周期正确。 - **MEM-11**:Memory V2 不可用时按配置降级到 legacy,核心聊天仍可用。 - **MEM-12**:大量记忆按相关性和 token 限额截断,并保留来源追踪。 +- **MEM-13**:routing prompt、Memory Context、skill/tool 指令和系统模板不得被提取为用户记忆。 +- **MEM-14**:单条证据消息的记忆提取数量和 fan-out 有硬上限,异常输入不能生成数百或数千条记忆。 +- **MEM-15**:重复处理同一证据保持幂等,精确和语义重复均被去重,且每条记忆必须保留合法 evidence/source。 +- **MEM-16**:用户纠正、遗忘或删除的内容,不能被 episodic、legacy 或上下文重摄取重新写回。 -### 7.5 文件和附件基础能力(7) +### 7.5 文件和附件基础能力(8) - **FILE-01**:图片上传、预览、关联会话并可被 Agent 读取。 - **FILE-02**:XLSX 上传、解析和附件选择正确。 @@ -246,6 +261,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **FILE-05**:多附件的选择、顺序、取消选择和会话复用正确。 - **FILE-06**:损坏文件、错误 MIME、超限文件和不支持格式明确拒绝。 - **FILE-07**:附件访问令牌过期后失效,跨用户访问被拒绝。 +- **FILE-08**:生成文件、公开 URL、下载按钮和微信原生交付均指向真实可下载 artifact,访问令牌和权限正确。 ### 7.6 根据图片生成页面(8) @@ -258,7 +274,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **IMGPG-07**:后续修改文案或布局时保留原图片和页面地址。 - **IMGPG-08**:图片不可读或用户无权限时明确失败,不伪造识别结果。 -### 7.7 Excel/CSV 分析并生成页面(10) +### 7.7 Excel/CSV 分析并生成页面(12) - **XLS-01**:单工作表生成指标摘要和可访问页面。 - **XLS-02**:多工作表关联分析得到 fixture 规定的结果。 @@ -270,8 +286,10 @@ LLM 场景不得只断言固定文案,应优先断言: - **XLS-08**:后续对话修改分析维度,无需重新上传文件。 - **XLS-09**:页面保留原文件下载入口和数据来源说明。 - **XLS-10**:Excel 私有数据默认不公开,用户之间严格隔离。 +- **XLS-11**:约 5MB、多工作表 Excel 在 xlsx 工具超时时安全降级,数字经过验证并仍能交付真实 artifact。 +- **XLS-12**:新 Excel 能增量更新已有数据看板,保留页面身份、既有数据和用户已确认的纠正。 -### 7.8 页面生成和 AI 配图(10) +### 7.8 页面生成和 AI 配图(13) - **PAGE-01**:纯文字需求生成公开页面并返回可访问链接。 - **PAGE-02**:长内容整理为适合阅读和分享的富页面。 @@ -283,8 +301,11 @@ LLM 场景不得只断言固定文案,应优先断言: - **PAGE-08**:`edit_file` 修改必须 materialize 到 `public/*.html`。 - **PAGE-09**:多页面任务只更新本轮相关页面,不污染其他页面。 - **PAGE-10**:文件名冲突、canonical、CSP、公开链接和域名正确。 +- **PAGE-11**:复合每日资讯页同时覆盖当日新闻、天气、市场/股票、知识卡片、权威引用、合规说明和可访问公开页。 +- **PAGE-12**:首次缺少公开 HTML、artifact 或必要交付物时只允许一次有界自修复;仍缺失则形成正确失败终态。 +- **PAGE-13**:独立生图请求不得被强制转成页面;用户说“换一个”时必须产生本轮新位图,不能复用旧资产。 -### 7.9 Page Data、问卷和业务后台(12) +### 7.9 Page Data、问卷和业务后台(13) - **DATA-01**:AI 使用调查完成页面、dataset、提交和后台查看。 - **DATA-02**:客户下单系统前台提交和后台管理完整。 @@ -298,6 +319,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **DATA-10**:dataset、page policy 和 workspace 绑定一致。 - **DATA-11**:remote 模式同步,storage 缺失时回退 workspace。 - **DATA-12**:跨用户、SQL 注入、越权字段和审计日志验证通过。 +- **DATA-13**:不完整 schema 能安全修复并重新绑定,覆盖缺字段、重复 `created_at`、误判只读、禁用软删、legacy API/client/localStorage 和过期页面版本。 ### 7.10 搜索和 Deep Search(8) @@ -310,7 +332,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **SEARCH-07**:搜索超时、取消和 Provider fallback 正确。 - **SEARCH-08**:容器内 loopback 地址正确重写为 host gateway。 -### 7.11 Agent、goosed、worker 和代码执行(9) +### 7.11 Agent、goosed、worker 和代码执行(11) - **AGENT-01**:普通 Agent 任务可由 goosed 成功执行。 - **AGENT-02**:并发任务分配和 session affinity 正确。 @@ -321,6 +343,8 @@ LLM 场景不得只断言固定文案,应优先断言: - **AGENT-07**:获得授权的用户可以执行 Aider 开发任务。 - **AGENT-08**:未授权用户和非允许 task type 被拒绝。 - **AGENT-09**:代码产物正确交付,不能逃逸用户工作区。 +- **AGENT-10**:扩展启动兼容目标数据库方言、PostgreSQL role 权限和模块路径;Page Data 扩展失败不能拖垮非数据任务。 +- **AGENT-11**:超时、心跳过期或 worker 重启后任务恰好恢复或终结一次,只产生一个终态和一份用户可见结果。 ### 7.12 日程和提醒(5) @@ -330,7 +354,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **SCHED-04**:worker 只发送一次,失败重试不重复通知。 - **SCHED-05**:服务重启后提醒仍存在并按计划执行。 -### 7.13 微信渠道(8) +### 7.13 微信渠道(13) - **WX-01**:签名和连通性验证正确。 - **WX-02**:重复消息 ID 不重复执行。 @@ -340,6 +364,11 @@ LLM 场景不得只断言固定文案,应优先断言: - **WX-06**:服务号页面使用本轮新缩略图,缺失时按规则修复。 - **WX-07**:OAuth 回调、return URL 和 state 校验正确。 - **WX-08**:支付通知签名、幂等和渠道隔离通过沙箱数据验证。 +- **WX-09**:每条入站消息最终进入 done 或 failed,长期 processing 记录能够自动恢复或关闭。 +- **WX-10**:仅图片的历史和内容能为纯文本模型安全归一化,图片后的文字追问不会触发 `image_url` 400。 +- **WX-11**:微信 45047 响应次数和 45015 时间窗口错误可恢复,不重复发送,并在下一次被动回复机会完成交付。 +- **WX-12**:AMR 语音转换及 ASR 成功链路可用,转换或识别失败时给出明确用户提示。 +- **WX-13**:微信页面交付为原子组合:真实页面 artifact、本轮封面、摘要、品牌标记和可发送公开链接缺一不可。 ### 7.14 计费、额度和充值(7) @@ -351,7 +380,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **BILL-06**:充值回调签名和幂等正确。 - **BILL-07**:余额不足时拒绝执行且不创建残留任务。 -### 7.15 MindSpace 和交付物(8) +### 7.15 MindSpace 和交付物(9) - **MS-01**:MindSpace `/health` 和 `/mindspace/v1/contract` 正常。 - **MS-02**:local/remote adapter 的核心行为一致。 @@ -361,6 +390,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **MS-06**:DOCX 和长图下载可用,链接不指向本地地址。 - **MS-07**:内容扫描、CSP 和危险脚本处理正确。 - **MS-08**:删除、清理和 purge 不留下孤立资源。 +- **MS-09**:PDF/DOCX 生成不依赖运行时联网下载字体,真实文件可下载,并可通过原生或直接链路交付。 ### 7.16 Plaza 集成(5) @@ -370,7 +400,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **PLAZA-04**:推荐和排序在固定 fixture 下稳定。 - **PLAZA-05**:页面删除或转私密后不再公开访问。 -### 7.17 配置、功能开关和可观测性(7) +### 7.17 配置、功能开关和可观测性(8) - **CFG-01**:Orchestrator 关闭时不成为 Portal 启动或请求依赖。 - **CFG-02**:Orchestrator shadow 模式不改变用户结果。 @@ -379,6 +409,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **CFG-05**:System Disclosure 的 shadow/enforce 行为正确。 - **CFG-06**:环境变量迁移到数据库配置且重复执行幂等。 - **CFG-07**:日志、指标和审计可追踪,且不泄露密钥和用户隐私。 +- **CFG-08**:活动 Provider 接受全部已配置数据库模型;模型目录升级时自动迁移或明确拒绝 legacy 配置,不把无效模型带入运行。 ### 7.18 浏览器和移动端(8) @@ -391,7 +422,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **UI-07**:刷新、前进后退和重新进入会话不丢状态。 - **UI-08**:无控制台错误、无布局重叠,并通过基础可访问性检查。 -### 7.19 生产兼容、故障恢复和性能(8) +### 7.19 生产兼容、故障恢复和性能(9) - **COMP-01**:脱敏生产旧会话升级后可以继续聊天。 - **COMP-02**:现有公开页面升级后仍可访问且内容未损坏。 @@ -401,6 +432,7 @@ LLM 场景不得只断言固定文案,应优先断言: - **COMP-06**:数据库短暂不可用后服务能够恢复并保持一致性。 - **COMP-07**:图片、搜索或记忆 Provider 故障不拖垮核心聊天。 - **COMP-08**:并发聊天、文件分析和页面生成满足延迟、错误率和资源泄漏阈值。 +- **COMP-09**:每次候选发布都在隔离环境回放脱敏生产回归语料库,且执行器从代码层禁止连接生产。 ## 8. 既有场景迁移 @@ -435,17 +467,19 @@ LLM 场景不得只断言固定文案,应优先断言: - `browser` - `rollback` -## 9. 建议命令和报告接口 +## 9. 自动化命令和报告接口 -实施后统一提供以下入口: +当前已提供以下入口: ```bash -npm run test:release-gate:deterministic -npm run test:release-gate:scenarios -npm run test:release-gate:browser -npm run test:release-gate:providers -npm run test:release-gate:upgrade -npm run test:release-gate:all +npm run test:release-gate:unit +npm run test:release-gate:deterministic -- --artifact .runtime/portal +npm run test:release-gate:scenarios -- --artifact .runtime/portal +npm run test:release-gate:browser -- --artifact .runtime/portal +npm run test:release-gate:providers -- --artifact .runtime/portal +npm run test:release-gate:upgrade -- --artifact .runtime/portal +npm run test:release-gate:all -- --artifact .runtime/portal +npm run verify:release-gate-report -- --artifact .runtime/portal ``` `test:release-gate:all` 生成: @@ -459,9 +493,18 @@ npm run test:release-gate:all ├── environment.json ├── scenarios/ ├── screenshots/ -└── logs/ +├── logs/ +└── partials/ ``` +当前执行器从本文解析并校验 187 个连续唯一编号。现有自动化 suite 已为 184 +个场景提供逐项业务断言,仓库/候选检查另直接判定 `REL-01`、`REL-02` 和 +`REL-04`,共 187 个场景都有自动判定能力。未执行 mode 仍为 `unknown`; +缺少 active 脱敏回归 fixture 时 `COMP-09` 明确失败;候选不等于 `origin/main` +或缺少成功 CI 证据时 `REL-02` 明确失败。任何不完整证据都会让完整闸门返回非零, +这是发布保护而不是测试故障。精确分组覆盖、命令和扩展方式见 +[自动发布闸门实现说明](release-gate-automation.md)。 + `report.json` 至少包含: ```json @@ -475,11 +518,13 @@ npm run test:release-gate:all "expires_at": "", "environment_fingerprint": "", "summary": { - "required": 160, - "passed": 160, + "required": 187, + "passed": 187, "not_applicable": 0, "failed": 0, "skipped": 0, + "blocked": 0, + "unknown": 0, "cleanup_failed": 0 }, "scenarios": [], @@ -488,16 +533,22 @@ npm run test:release-gate:all } ``` -如果存在合法 `not_applicable`,`required` 仍为 160,且必须满足: +如果存在合法 `not_applicable`,`required` 仍为 187,且必须满足: ```text passed + not_applicable = required -failed = skipped = cleanup_failed = 0 +failed = skipped = blocked = unknown = cleanup_failed = 0 ``` ## 10. 发布脚本的最终阻断点 -`scripts/release-portal-runtime-prod.sh` 在实施本守门员后必须按以下顺序执行: +### 10.0 用户级灰度前置条件 + +当前 Portal 发布脚本会替换 8081 live 目录,不具备版本级用户路由能力,因此在用户级灰度代理和候选 runtime 并行运行方案完成前,禁止调用该脚本执行生产发布。灰度身份必须使用不可变用户 ID/用户名/微信绑定 ID;禁止按显示昵称(包括“唐”)匹配。未命中或身份解析失败必须回落稳定版本,候选不可用必须自动回退。 + +本地路由契约见 [docs/release-canary-103.md](release-canary-103.md),对应测试为 `release-gate/canary-routing.test.mjs`。该测试通过只代表身份匹配契约通过,不代表 103 已具备灰度入口。 + +`scripts/release-portal-runtime-prod.sh` 必须按以下顺序执行: 1. `check-release-ready.sh` 2. 获取完整 `main` SHA 和远端 CI 状态 @@ -521,6 +572,8 @@ failed = skipped = cleanup_failed = 0 - 103 有活动任务且未完成排空 - 备份、磁盘空间、数据库锁或回滚预检失败 +当前脚本已在任何 103 SSH、上传或切换之前执行 `verify-release-gate-report.mjs`,并硬拒绝 `--skip-tests`、`ALLOW_PORTAL_RELEASE_SCOPE_BYPASS` 和 `ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES`。Gate report 绑定 `.runtime/portal` 的排序目录树 SHA256;打包后的传输压缩包另行记录自身 SHA256。 + ## 11. 发布后的最小保护 生产切换后立即执行只产生最小业务影响的烟雾验证: @@ -547,3 +600,115 @@ failed = skipped = cleanup_failed = 0 6. 发布人不能单独审批自己提出的豁免。 7. Gate report 和发布 manifest 应按 release ID 归档。 8. 本文、`PRODUCTION_RELEASE_RULES.md` 和发布脚本的规则必须保持一致。 + +## 13. 生产失败回归语料库 + +### 13.1 2026-07-26 只读审计基线 + +以下数字仅用于说明场景来源和风险优先级,是一次性时间点证据,不是生产实时不变量,也不能替代发布前重新验证: + +| 对象 | 审计结果 | 对应守卫重点 | +|---|---:|---| +| 注册用户 / 有聊天用户 | 122 / 70 | 用户隔离、旧数据兼容、长尾任务 | +| 会话快照 / 快照消息 | 739 / 16,628 | Finish、恢复、消息顺序、历史契约 | +| 规范化消息 | 10,009 | 快照与规范化消息一致性 | +| 快照中未进入规范化表的用户或助手文本 | 824 | CHAT-13、CHAT-15 | +| Agent Run | 1,297,失败 108(8.33%) | 故障分类、终态、恢复与真实交付 | +| 当前生产版本以来 Agent Run(自 2026-07-23 17:48:55 +08:00) | 42,失败 8(19.05%) | 当前版本 P0 回归样本 | +| 微信消息 | 1,701,失败 245,仍 processing 17 | 微信终态、窗口限制、图片和交付 | +| 当前生产版本以来微信消息(自 2026-07-23 17:48:55 +08:00) | 78,失败 13 | 当前版本 P0 微信回归样本 | +| 活跃记忆 / 有 evidence 的记忆 | 8,709 / 418 | 来源追踪和异常扩张 | +| 缺少 evidence/source 的活跃记忆 | 3,457 | MEM-15 | +| 单条 evidence 最大记忆数 | 1,019 | MEM-14 | +| 精确重复组 / 额外重复记录 | 164 / 182 | MEM-15 | +| 疑似内部提示型记忆 | 13 | MEM-13、MEM-16 | + +Agent 失败按规范化根因聚类后,至少覆盖以下 14 类 fixture: + +| 根因 | 审计计数 | 场景映射 | +|---|---:|---| +| Page Data schema、绑定或扩展错误 | 33 | DATA-10、DATA-13、AGENT-10 | +| 心跳过期或 stale run | 12 | AGENT-11 | +| 已有活动请求冲突 | 10 | CHAT-11 | +| 缺少公开页或交付物 | 9 | PAGE-12、FILE-08、WX-13 | +| 工具或任务超时 | 7 | XLS-11、AGENT-11 | +| Finish / 终态代码异常 | 7 | CHAT-06、CHAT-13、CHAT-15 | +| 生图工具或结果异常 | 7 | PAGE-04、PAGE-06、PAGE-13 | +| 数据库 role / 权限错误 | 6 | AGENT-10 | +| 历史消息契约异常 | 5 | CHAT-12 | +| 数据库方言错误 | 4 | AGENT-10 | +| runtime 模块缺失 | 4 | REL-11、AGENT-10 | +| request ID 格式异常 | 2 | CHAT-12 | +| 数据库连接池耗尽 | 1 | AGENT-11、COMP-08 | +| worker 重启中断 | 1 | AGENT-11 | + +### 13.2 回归语料结构 + +建议在实现阶段建立以下目录;本节只定义契约,不代表目录或执行器已经创建: + +```text +scenarios/production-regressions/ +├── agent-run/ +├── page-data/ +├── memory/ +├── wechat/ +├── files/ +└── manifests/ +``` + +每个 fixture 必须包含: + +- 稳定 ID,例如 `PRC-AGENT-001`,以及映射的 guardian scenario ID。 +- 脱敏后的用户意图和最小合成输入,不保存原始聊天全文。 +- 规范化失败签名、触发能力、依赖和故障注入条件。 +- 预期终态、用户可见结果、数据库/文件断言和清理断言。 +- 受控审计批次 ID、不可逆且不可反查用户的证据摘要、首次发现版本和修复版本,用于审计追踪。 +- `contains_personal_data=false`、隐私审核人和审核时间。 + +严禁写入 Git 或 Gate report: + +- 原始用户聊天、记忆正文、附件内容或生产数据库快照。 +- 姓名、邮箱、手机号、OpenID、Cookie、token、密钥、内部用户 ID 和可反查标识。 +- 生产文件 URL、workspace 路径或能够重新定位用户资源的哈希。 + +### 13.3 从生产失败晋升为回归用例 + +1. 对生产只做授权范围内的只读聚合;原始数据留在受控环境。 +2. 按语义根因去重,不能简单地把每条失败记录复制成一个测试。 +3. 用合成用户、合成文件和最小输入重建失败,替换全部身份和业务内容。 +4. 完成隐私审核,确认无法从 fixture 反推出用户或原始任务。 +5. 将 fixture 映射到至少一个场景 ID;新能力或新根因必须新增场景。 +6. 在隔离环境复现旧版本失败,并验证修复版本通过,保存确定性证据。 +7. 当前版本审计发现的 8 个 Agent 失败和 13 个微信失败必须先去重、脱敏并进入 P0 回归清单。 + +未完成脱敏、无法稳定重建或仍依赖生产数据的记录,保留为待分析证据,不能伪装成已覆盖测试。 + +### 13.4 闸门判定 + +- `COMP-09` 每次发布强制执行,不能标记为 `not_applicable`。 +- 回归语料库中的全部 active fixture 必须通过;`failed`、`skipped`、`unknown` 和未执行均阻断。 +- fixture 只能使用隔离数据库、合成账号、Stub 或独立 Canary,执行器必须拒绝生产地址和凭据。 +- 新生产事故或用户任务失败在归因后必须进入回归待办;修复关闭前必须落为可回放 fixture 或给出经审核的不可自动化理由。 +- 历史 fixture 只在对应功能已删除且完成审核后才能退役,不能因为不稳定或失败而删除。 + +### 13.5 首批强制参数化 case + +场景族通过不等于只跑一条 happy path。首批实现必须至少包含下表参数;同一行可以由多个 fixture 覆盖,但报告必须能反查到每个参数的执行证据: + +| 场景 | 必测参数 | +|---|---| +| CHAT-11 | active request 下排队、取消、新会话;前一任务成功、失败、超时三种终态 | +| CHAT-12 | legacy `userVisible`/`agentVisible`、非 UUID request ID、缺失可选字段、损坏 tool history | +| CHAT-13、CHAT-15 | 单字回复、Finish 同批到达、Finish 先后乱序、SSE 断线、轮询恢复、重复同步 | +| MEM-13 至 MEM-16 | routing/skill/tool prompt、无 evidence、单证据超量、重复摄取、语义重复、纠正、遗忘、legacy 回灌 | +| FILE-08 | 登录下载、公开下载、token 过期、跨用户、按钮 URL、微信 media/link 交付 | +| XLS-11、XLS-12 | 约 5MB、多 sheet、工具超时、降级解析、数字校验、已有看板增量更新、用户纠正保留 | +| PAGE-11 至 PAGE-13 | 搜索 Provider 失败不得伪造来源;缺 HTML/artifact/封面/摘要/品牌;强制生图无工具、无有效位图及“换一个” | +| DATA-13 | `survey_page_should_not_be_read_only`、缺 page-data client、legacy API、localStorage、缺字段、重复 `created_at`、禁用软删、过期页面版本 | +| AGENT-10、AGENT-11 | SQLite/MySQL/PostgreSQL 方言、role 权限、模块缺失、连接池耗尽、timeout、stale heartbeat、worker restart | +| WX-09 至 WX-13 | 重复 callback 不重复执行/扣费、stale processing、图片历史 + 文字追问、45047、45015、AMR/ASR 失败、原子页面交付 | +| MS-09 | 无网络字体、缺字体、PDF/DOCX 生成失败、文件不存在、直接下载和原生渠道交付 | +| CFG-08 | 当前模型、legacy 模型、未知模型、Provider 切换、目录升级迁移,例如无效 `deepseek-chat` 到有效目录项 | +| COMP-09 | 每个 active 生产回归 fixture、生产地址拒绝、生产凭据拒绝、清理失败 | + +任何参数缺少结果时,对应场景状态必须为 `failed` 或 `unknown`,不能只用场景族中的其他 case 通过来覆盖。 diff --git a/docs/release-canary-103.md b/docs/release-canary-103.md new file mode 100644 index 0000000..5632ddd --- /dev/null +++ b/docs/release-canary-103.md @@ -0,0 +1,13 @@ +# 103 用户级灰度发布设计 + +当前 `scripts/release-portal-runtime-prod.sh` 是整包替换脚本,不能用于“只让 john 和精确微信用户唐”灰度。因此在闸门通过前禁止调用它进行生产发布。 + +候选版本必须与稳定版本并行运行,由入口代理依据登录后的不可变身份选择版本: + +- `john`:按唯一用户名或数据库用户 ID 匹配。 +- 微信用户“唐”:按绑定的不可变用户 ID `wx_ul610et8` 匹配;禁止按昵称匹配,避免与其他同名用户串流。 +- 未命中、身份缺失、解析异常:全部回落到稳定版本。 + +候选版本必须使用独立监听端口和独立 runtime 目录,但共享经审计的持久数据与数据库连接;灰度入口必须支持 SSE/附件/公开页请求的完整转发,并保留稳定版本回退。 + +上线前必须在本地证明:命中用户只到候选版本、其他用户只到稳定版本、同名非目标用户不命中、身份解析失败回落稳定、候选不可用自动回退。生产发布仍需在 `main` 干净提交、187 场景全通过并获得单独的生产灰度确认后执行;本设计不会触发生产动作。 diff --git a/docs/release-gate-automation.md b/docs/release-gate-automation.md new file mode 100644 index 0000000..04eb180 --- /dev/null +++ b/docs/release-gate-automation.md @@ -0,0 +1,171 @@ +# 自动发布闸门实现说明 + +## 当前状态 + +自动发布闸门采用 fail-closed 设计。机器目录从 `production-release-guardian.md` 加载并校验 187 个连续、唯一场景编号;执行器不能把没有实现、没有执行或没有证据的场景视为成功。 + +已经实现: + +- `release-gate/catalog.mjs`:187 场景机器目录和永远不可豁免清单。 +- `release-gate/safety.mjs`:拒绝生产 IP、`*.tkmind.cn`、生产路径和生产关联环境变量。 +- `release-gate/artifact.mjs`:runtime 排序目录树 SHA256、依赖闭包和持久数据扫描。 +- `release-gate/runner.mjs`:suite 执行、日志归档和失败关闭。 +- `release-gate/report.mjs`:`report.json`、Markdown、JUnit、环境指纹及四小时有效期。 +- `release-gate/regression-corpus.mjs`:脱敏生产回归 fixture 和 active manifest 校验。 +- `scripts/verify-release-gate-report.mjs`:校验完整 `main` SHA、artifact SHA、187 项状态和合法豁免。 +- `scripts/release-portal-runtime-prod.sh`:任何 103 连接或上传之前强制验证 Gate report。 + +当前已经有自动判定证据的场景如下。这里的“覆盖”必须同时满足 +`release-gate/coverage.mjs` 中逐场景的业务断言;仅仅执行过一个通用测试文件不能计入。 + +| 场景组 | 已自动判定 | 总数 | 已覆盖编号 | +|---|---:|---:|---| +| REL | 11 | 11 | REL-01 至 REL-11 | +| AUTH | 8 | 8 | AUTH-01 至 AUTH-08 | +| CHAT | 15 | 15 | CHAT-01 至 CHAT-15 | +| MEM | 16 | 16 | MEM-01 至 MEM-16 | +| FILE | 8 | 8 | FILE-01 至 FILE-08 | +| IMGPG | 8 | 8 | IMGPG-01 至 IMGPG-08 | +| XLS | 12 | 12 | XLS-01 至 XLS-12 | +| PAGE | 13 | 13 | PAGE-01 至 PAGE-13 | +| DATA | 13 | 13 | DATA-01 至 DATA-13 | +| SEARCH | 8 | 8 | SEARCH-01 至 SEARCH-08 | +| AGENT | 11 | 11 | AGENT-01 至 AGENT-11 | +| SCHED | 5 | 5 | SCHED-01 至 SCHED-05 | +| WX | 13 | 13 | WX-01 至 WX-13 | +| BILL | 7 | 7 | BILL-01 至 BILL-07 | +| MS | 9 | 9 | MS-01 至 MS-09 | +| PLAZA | 5 | 5 | PLAZA-01 至 PLAZA-05 | +| CFG | 8 | 8 | CFG-01 至 CFG-08 | +| UI | 8 | 8 | UI-01 至 UI-08 | +| COMP | 9 | 9 | COMP-01 至 COMP-09 | +| **合计** | **187** | **187** | 184 个 suite 映射,加 REL-01/REL-02/REL-04 三项直接检查 | + +`REL-03` 会比较候选和两次干净重建的排序目录树 SHA256;`REL-06`、`REL-07` +分别执行生产配置冷启动和脱敏旧库升级,并断言运行前后 artifact 哈希不变;`REL-11` +在无网络 Linux ARM64 容器中实际加载 Portal、WeChat、worker 和每个 MCP 模块。 + +全部 187 项都已有自动判定路径,未执行的 mode 仍保持 `unknown`。UI-01 至 UI-08 +在隔离本地 Portal、390×844 移动视口和公开页 fixture 上运行;COMP-09 由独立执行器 +要求 active 脱敏回归 manifest 并逐条回放。没有 manifest 时 COMP-09 明确 `failed`, +不会因“没有用例”而通过。`REL-02` 只在候选等于 `origin/main` 且 CI 注入 +`MEMIND_RELEASE_CI_STATUS=success` 时通过。 + +2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能 +分支且工作区不干净而失败。PAGE-01/02 与 DATA-01/02/03/04 的隔离栈现在使用后台 +直连 LLM(`custom_deepseek` / `deepseek-v4-pro`),关闭 relay bootstrap,并将代码/技能 +根与隔离数据根分离,避免技能目录为空导致普通用户丢失 `static_publish` 工具。页面发布 +约束也明确要求 `sandbox-fs__write_file/edit_file` 命名空间,避免模型误调用不存在的裸工具。 +修复后的真实 Agent 回归仍需在最新 runtime 上完成全 6 项终态断言;在此之前不能伪装成通过, +也不能标记为合法 `not_applicable`。生产发布继续禁止。 + +## 本地命令 + +先运行框架单元测试: + +```bash +npm run test:release-gate:unit +``` + +构建候选 runtime 后运行完整闸门: + +```bash +npm run build:portal-runtime +npm run test:release-gate:all -- --artifact .runtime/portal +``` + +完整报告路径: + +```text +.release-gate//report.json +``` + +单层执行结果存放在: + +```text +.release-gate//partials// +``` + +只验证已有完整报告与当前 artifact: + +```bash +npm run verify:release-gate-report -- --artifact .runtime/portal +``` + +### 耗时控制 + +完整覆盖不等于 187 条都等待在线 Agent 串行执行: + +- 业务规则、权限、账务、记忆、文件和交付契约优先做成确定性 fixture/replay,随 + `deterministic` 模式并行执行。 +- Gate 执行器默认同时运行 4 个独立 suite,可通过 + `--suite-concurrency` 或 `RELEASE_GATE_SUITE_CONCURRENCY` 在 1~16 之间调整; + 每个 suite 仍写独立日志并单独判定。 +- 必须验证真实 Agent 的场景按不同 session 并行;Page Data 场景默认并发 4, + 单个 Agent 步骤上限 300 秒、子进程总上限 360 秒。超时直接失败,不允许无限等待。 +- 可用 `RELEASE_GATE_SCENARIO_CONCURRENCY`、 + `RELEASE_GATE_SCENARIO_TIMEOUT_MS` 和 + `RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS` 在 CI 资源范围内收紧预算,不得用它们 + 放宽为无限超时。 +- 完整闸门只为干净 `main` 的精确 commit + artifact 生成一次四小时有效报告; + 发布脚本复用并校验这份报告,不重复跑慢场景。commit、artifact 或环境指纹变化后 + 证据立即失效并重新执行。 +- 本地开发先跑相关确定性 suite;只有形成发布候选时才聚合全部 187 项。任何未执行、 + 超时或缺证据项仍保持 `unknown/failed` 并阻断发布。 + +报告出现以下任意状态都会返回非零: + +- `failed` +- `skipped` +- `blocked` +- `unknown` +- `cleanup_failed` +- 缺少场景 +- 非法 `not_applicable` +- commit、branch 或 artifact SHA 不匹配 +- 报告超过四小时 + +## 增加自动化场景 + +1. 先在 `release-gate/` 或现有模块中实现确定性测试。 +2. 在 `release-gate/coverage.mjs` 新增 suite,明确列出唯一场景 ID 和可执行命令。 +3. 一个场景只有在守门员列出的所有强制参数 case 都有断言时,才能映射为 `passed`。 +4. 为 suite 失败、超时和日志路径增加框架测试。 +5. 运行 `npm run test:release-gate:unit` 和对应 mode,确认失败时不会被吞掉。 +6. 更新本文的覆盖表。 + +禁止把大型通用测试命令直接映射到大量场景并据此宣称覆盖;每个映射必须能指出真实业务断言。 + +## 生产失败回归 fixture + +active manifest 约定位置: + +```text +scenarios/production-regressions/manifests/active.json +``` + +每个 active fixture 必须位于 approved bucket,具有: + +- `PRC--NNN` ID。 +- 一个或多个 guardian scenario ID。 +- 合成意图、规范化失败签名和可执行 replay 命令。 +- 不可反查用户的证据摘要和审计批次 ID。 +- `contains_personal_data=false`。 +- 隐私审核人和时间。 + +当前 active manifest 已包含 `PRC-REL-001`,用于回放 runtime worker 的 18036 端口冲突 +回归。原始聊天、用户身份、附件和生产资源地址不得进入仓库或 Gate report。 + +## 发布脚本保护 + +生产脚本执行顺序固定为: + +1. 检查干净、完整且不落后远端的 `main`。 +2. 运行既有本地 verify。 +3. 构建并检查 `.runtime/portal`。 +4. 验证与当前 commit 和 runtime tree SHA256 完全一致的完整 Gate report。 +5. Gate report 通过后才允许执行 103 只读预检。 +6. 再次请求本次发布的明确人工确认。 +7. 确认后才允许上传和切换。 + +`--skip-tests`、范围绕过变量和公开页问题绕过变量均被硬拒绝。 diff --git a/excel-analysis-engine.mjs b/excel-analysis-engine.mjs index 6ec2662..5ef390d 100644 --- a/excel-analysis-engine.mjs +++ b/excel-analysis-engine.mjs @@ -75,6 +75,25 @@ function isWithinRoot(root, candidate) { return candidate === root || candidate.startsWith(`${root}${path.sep}`); } +function safeSourceDownloadUrl(value) { + const raw = normalizeText(value); + if (!raw) return null; + let parsed; + try { + parsed = new URL(raw, 'http://local.invalid'); + } catch { + throw Object.assign(new Error('Excel 原始文件下载地址无效'), { code: 'INVALID_SOURCE_DOWNLOAD_URL' }); + } + if (parsed.origin !== 'http://local.invalid' + || !parsed.pathname.startsWith('/api/mindspace/v1/assets/') + || !parsed.pathname.endsWith('/download')) { + throw Object.assign(new Error('Excel 原始文件下载地址必须是受保护的站内资产下载路径'), { + code: 'INVALID_SOURCE_DOWNLOAD_URL', + }); + } + return `${parsed.pathname}${parsed.search}`; +} + function validateZipBudget(buffer, { maxUncompressedBytes = DEFAULT_MAX_UNCOMPRESSED_BYTES, maxEntries = DEFAULT_MAX_ZIP_ENTRIES, @@ -652,7 +671,15 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) { const validation = { valid: errors.length === 0, errors, warnings }; return { schemaVersion: 1, - source: { path: args.path, sheet: worksheet.name, headerRow: loaded.headerRow, rows: loaded.rows.length }, + source: { + path: args.path, + sheet: worksheet.name, + headerRow: loaded.headerRow, + rows: loaded.rows.length, + ...(args.source_download_url + ? { downloadUrl: safeSourceDownloadUrl(args.source_download_url) } + : {}), + }, fields: { metric, profitMetric, quantityMetric, orderId, dimensions }, totals: { metric: roundNumber(totalMetric), @@ -699,7 +726,11 @@ export function createExcelAnalysisEngine({ workspaceRoot, maxFileBytes = DEFAUL const { workbook, source, sizeBytes } = await loadWorkbook(args.path); const sampleRows = asPositiveInteger(args.sample_rows, DEFAULT_SAMPLE_ROWS, 20); const sheets = workbook.worksheets.map((worksheet) => { - const loaded = loadRows(worksheet, { headerRow: args.header_row }); + const loaded = loadRows(worksheet, { + headerRow: args.header_row, + maxRows: args.max_rows, + maxCells: args.max_cells, + }); const profiles = loaded.headers.map((name) => profileColumn(loaded.rows, name)); return { name: worksheet.name, @@ -725,7 +756,11 @@ export function createExcelAnalysisEngine({ workspaceRoot, maxFileBytes = DEFAUL async analyze(args = {}) { const { workbook, source } = await loadWorkbook(args.path); const worksheet = worksheetByName(workbook, args.sheet); - const loaded = loadRows(worksheet, { headerRow: args.header_row }); + const loaded = loadRows(worksheet, { + headerRow: args.header_row, + maxRows: args.max_rows, + maxCells: args.max_cells, + }); const operation = normalizeText(args.operation || 'summary').toLowerCase(); let result; if (operation === 'summary') { @@ -754,7 +789,11 @@ export function createExcelAnalysisEngine({ workspaceRoot, maxFileBytes = DEFAUL async chart(args = {}) { const { workbook, source } = await loadWorkbook(args.path); const worksheet = worksheetByName(workbook, args.sheet); - const loaded = loadRows(worksheet, { headerRow: args.header_row }); + const loaded = loadRows(worksheet, { + headerRow: args.header_row, + maxRows: args.max_rows, + maxCells: args.max_cells, + }); const type = normalizeText(args.type || 'bar').toLowerCase(); if (!['bar', 'line'].includes(type)) { throw Object.assign(new Error('Phase 1 图表仅支持 bar 或 line'), { code: 'INVALID_CHART_TYPE' }); @@ -784,7 +823,11 @@ export function createExcelAnalysisEngine({ workspaceRoot, maxFileBytes = DEFAUL async report(args = {}) { const { workbook, source } = await loadWorkbook(args.path); const worksheet = worksheetByName(workbook, args.sheet); - const loaded = loadRows(worksheet, { headerRow: args.header_row }); + const loaded = loadRows(worksheet, { + headerRow: args.header_row, + maxRows: args.max_rows, + maxCells: args.max_cells, + }); const model = buildReportModel(workbook, worksheet, loaded, { ...args, path: source.relative }); if (!model.validation.valid) { throw Object.assign(new Error('Excel 报告事实校验失败,未生成报告'), { @@ -818,6 +861,7 @@ export const excelAnalysisInternals = { safeRelativeWorkbookPath, safeChartOutputPath, safeReportOutputPath, + safeSourceDownloadUrl, normalizeMainNamespacePrefixes, validateZipBudget, }; diff --git a/excel-report-html.mjs b/excel-report-html.mjs index 5312729..7dc494c 100644 --- a/excel-report-html.mjs +++ b/excel-report-html.mjs @@ -832,10 +832,13 @@ ${REPORT_ENHANCED_STYLE}
-
+

📊 ${safeTitle}

${escapeXml(buildHeroSubtitle(model))}
${escapeXml(buildHeroSource(model))}
+ ${model.source.downloadUrl + ? `下载原始 Excel` + : ''}
diff --git a/release-gate/artifact.mjs b/release-gate/artifact.mjs new file mode 100644 index 0000000..cbc81f3 --- /dev/null +++ b/release-gate/artifact.mjs @@ -0,0 +1,159 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([ + 'server.mjs', + 'wechat-mp.bundle.mjs', + 'mindspace-sandbox-mcp.mjs', + 'tkmind-search-mcp.mjs', + 'tkmind-excel-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', +]); + +export const FORBIDDEN_PORTAL_RUNTIME_PATHS = Object.freeze([ + '.env', + '.git', + 'data', + 'dist/dev', + 'dist/hello-john.html', + 'users', + 'logs', + 'MindSpace', + '.tailscale', + 'public/plaza-covers', +]); + +export async function removeForbiddenPortalRuntimePaths(artifactPath) { + const root = path.resolve(artifactPath); + const segments = root.split(path.sep); + if (path.basename(root) !== 'portal' || !segments.includes('.runtime')) { + throw new Error('Refusing to sanitize a path outside an explicit .runtime/portal directory'); + } + for (const relative of FORBIDDEN_PORTAL_RUNTIME_PATHS) { + await fsp.rm(path.join(root, relative), { recursive: true, force: true }); + } +} + +export function assertPortalRuntimePath(artifactPath, { + repoRoot, +} = {}) { + const expected = path.resolve(repoRoot, '.runtime', 'portal'); + const actual = path.resolve(artifactPath); + if (actual !== expected) { + throw new Error(`Portal release gate artifact must be ${expected}`); + } + return actual; +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + await new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('error', reject); + stream.on('end', resolve); + }); + return hash.digest('hex'); +} + +async function walk(root, current = root) { + const entries = await fsp.readdir(current, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + const result = []; + for (const entry of entries) { + const absolute = path.join(current, entry.name); + const relative = path.relative(root, absolute).split(path.sep).join('/'); + const stat = await fsp.lstat(absolute); + if (stat.isDirectory()) { + result.push({ type: 'dir', path: relative, mode: stat.mode & 0o777 }); + result.push(...await walk(root, absolute)); + } else if (stat.isFile()) { + result.push({ + type: 'file', + path: relative, + mode: stat.mode & 0o777, + size: stat.size, + sha256: await sha256File(absolute), + }); + } else if (stat.isSymbolicLink()) { + const target = await fsp.readlink(absolute); + const resolved = path.resolve(path.dirname(absolute), target); + const relativeResolved = path.relative(root, resolved); + if (path.isAbsolute(target) || relativeResolved.startsWith(`..${path.sep}`) || relativeResolved === '..') { + throw new Error(`Artifact symlink escapes root: ${relative}`); + } + result.push({ type: 'symlink', path: relative, target }); + } else { + throw new Error(`Unsupported artifact entry: ${relative}`); + } + } + return result; +} + +export async function hashArtifact(artifactPath) { + const absolute = path.resolve(artifactPath); + const stat = await fsp.stat(absolute); + if (stat.isFile()) { + return { + sha256: await sha256File(absolute), + kind: 'file', + files: 1, + bytes: stat.size, + }; + } + if (!stat.isDirectory()) { + throw new Error(`Artifact must be a file or directory: ${absolute}`); + } + const entries = await walk(absolute); + const hash = crypto.createHash('sha256'); + for (const entry of entries) { + hash.update(JSON.stringify(entry)); + hash.update('\n'); + } + return { + sha256: hash.digest('hex'), + kind: 'directory-tree', + files: entries.filter((entry) => entry.type === 'file').length, + bytes: entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0), + entries, + }; +} + +export async function inspectPortalRuntime(artifactPath) { + const root = path.resolve(artifactPath); + const stat = await fsp.stat(root); + if (!stat.isDirectory()) { + throw new Error('Portal runtime inspection requires a directory artifact'); + } + const missing = []; + for (const relative of REQUIRED_PORTAL_RUNTIME_PATHS) { + try { + await fsp.access(path.join(root, relative)); + } catch { + missing.push(relative); + } + } + const forbidden = []; + for (const relative of FORBIDDEN_PORTAL_RUNTIME_PATHS) { + try { + await fsp.access(path.join(root, relative)); + forbidden.push(relative); + } catch { + // Expected: runtime artifacts must not contain persisted production state. + } + } + return { + missing, + forbidden, + passed: missing.length === 0 && forbidden.length === 0, + }; +} diff --git a/release-gate/artifact.test.mjs b/release-gate/artifact.test.mjs new file mode 100644 index 0000000..1777e66 --- /dev/null +++ b/release-gate/artifact.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + hashArtifact, + inspectPortalRuntime, + assertPortalRuntimePath, + removeForbiddenPortalRuntimePaths, + REQUIRED_PORTAL_RUNTIME_PATHS, +} from './artifact.mjs'; + +async function withTempDir(fn) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-release-gate-')); + try { + return await fn(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +test('directory artifact hash is stable across mtime changes', async () => { + await withTempDir(async (dir) => { + await fs.mkdir(path.join(dir, 'nested')); + const file = path.join(dir, 'nested', 'value.txt'); + await fs.writeFile(file, 'stable\n'); + const first = await hashArtifact(dir); + await fs.utimes(file, new Date(), new Date(Date.now() + 10_000)); + const second = await hashArtifact(dir); + assert.equal(first.sha256, second.sha256); + assert.equal(first.files, 1); + }); +}); + +test('artifact hashing refuses symlinks that escape the artifact root', async () => { + await withTempDir(async (dir) => { + await fs.symlink('/tmp', path.join(dir, 'escape')); + await assert.rejects(() => hashArtifact(dir), /symlink escapes root/); + }); +}); + +test('Portal runtime inspection requires dependency closure and no persisted state', async () => { + await withTempDir(async (dir) => { + for (const relative of REQUIRED_PORTAL_RUNTIME_PATHS) { + const absolute = path.join(dir, relative); + if (relative === 'dist') { + await fs.mkdir(absolute, { recursive: true }); + } else { + await fs.mkdir(path.dirname(absolute), { recursive: true }); + await fs.writeFile(absolute, 'fixture\n'); + } + } + assert.deepEqual(await inspectPortalRuntime(dir), { + missing: [], + forbidden: [], + passed: true, + }); + await fs.writeFile(path.join(dir, '.env'), 'SECRET=not-real\n'); + const unsafe = await inspectPortalRuntime(dir); + assert.deepEqual(unsafe.forbidden, ['.env']); + assert.equal(unsafe.passed, false); + }); +}); + +test('runtime sanitizer removes persisted paths before artifact hashing', async () => { + await withTempDir(async (dir) => { + const runtime = path.join(dir, '.runtime', 'portal'); + await fs.mkdir(path.join(runtime, 'public', 'plaza-covers'), { recursive: true }); + await fs.mkdir(path.join(runtime, 'dist', 'dev'), { recursive: true }); + await fs.mkdir(path.join(runtime, 'users'), { recursive: true }); + await fs.writeFile(path.join(runtime, '.env'), 'SECRET=fixture\n'); + await fs.writeFile(path.join(runtime, 'public', 'plaza-covers', 'cover.jpg'), 'fixture'); + await fs.writeFile(path.join(runtime, 'dist', 'dev', 'local-demo.html'), 'http://127.0.0.1'); + await fs.writeFile(path.join(runtime, 'dist', 'hello-john.html'), 'development fixture'); + await fs.writeFile(path.join(runtime, 'users', 'profile.json'), '{}'); + await fs.writeFile(path.join(runtime, 'server.mjs'), 'export {};\n'); + await removeForbiddenPortalRuntimePaths(runtime); + await assert.rejects(() => fs.access(path.join(runtime, '.env'))); + await assert.rejects(() => fs.access(path.join(runtime, 'dist', 'dev'))); + await assert.rejects(() => fs.access(path.join(runtime, 'dist', 'hello-john.html'))); + await assert.rejects(() => fs.access(path.join(runtime, 'users'))); + await assert.rejects(() => fs.access(path.join(runtime, 'public', 'plaza-covers'))); + await fs.access(path.join(runtime, 'server.mjs')); + }); +}); + +test('runtime sanitizer and gate artifact path refuse broad targets', async () => { + await withTempDir(async (dir) => { + await assert.rejects( + () => removeForbiddenPortalRuntimePaths(dir), + /Refusing to sanitize/, + ); + assert.throws( + () => assertPortalRuntimePath(dir, { repoRoot: dir }), + /must be/, + ); + assert.equal( + assertPortalRuntimePath(path.join(dir, '.runtime', 'portal'), { repoRoot: dir }), + path.join(dir, '.runtime', 'portal'), + ); + }); +}); diff --git a/release-gate/canary-routing.mjs b/release-gate/canary-routing.mjs new file mode 100644 index 0000000..077a133 --- /dev/null +++ b/release-gate/canary-routing.mjs @@ -0,0 +1,74 @@ +const TARGETS = new Set(['stable', 'candidate']); + +function csvSet(value) { + return new Set( + String(value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ); +} + +function normalizeIdentity(identity = {}) { + identity = identity ?? {}; + return { + id: String(identity.id ?? '').trim(), + username: String(identity.username ?? '').trim().toLowerCase(), + wechatUserId: String(identity.wechatUserId ?? identity.wechat_user_id ?? '').trim(), + }; +} + +/** + * Build a fail-closed, immutable-identity canary policy. Display names are + * intentionally not accepted: nicknames are not unique enough for production + * routing (there may be multiple users named “唐”). + */ +export function createCanaryPolicy({ + userIds = [], + usernames = [], + wechatUserIds = [], + candidateTarget = 'candidate', +} = {}) { + if (!TARGETS.has(candidateTarget) || candidateTarget === 'stable') { + throw new Error('candidateTarget must be candidate'); + } + const policy = { + userIds: userIds instanceof Set ? new Set(userIds) : csvSet(userIds), + usernames: usernames instanceof Set + ? new Set([...usernames].map((item) => String(item).trim().toLowerCase())) + : new Set([...csvSet(usernames)].map((item) => item.toLowerCase())), + wechatUserIds: wechatUserIds instanceof Set ? new Set(wechatUserIds) : csvSet(wechatUserIds), + candidateTarget, + }; + if (!policy.userIds.size && !policy.usernames.size && !policy.wechatUserIds.size) { + throw new Error('canary policy must contain at least one immutable user selector'); + } + return Object.freeze(policy); +} + +export function canaryPolicyFromEnv(env = process.env) { + return createCanaryPolicy({ + userIds: csvSet(env.MEMIND_RELEASE_CANARY_USER_IDS), + usernames: csvSet(env.MEMIND_RELEASE_CANARY_USERNAMES), + wechatUserIds: csvSet(env.MEMIND_RELEASE_CANARY_WECHAT_USER_IDS), + }); +} + +export function resolveCanaryTarget(identity, policy) { + if (!policy || typeof policy !== 'object') return 'stable'; + const normalized = normalizeIdentity(identity); + const matched = policy.userIds?.has(normalized.id) + || policy.usernames?.has(normalized.username) + || policy.wechatUserIds?.has(normalized.wechatUserId); + return matched ? policy.candidateTarget : 'stable'; +} + +export function describeCanaryMatch(identity, policy) { + const normalized = normalizeIdentity(identity); + const target = resolveCanaryTarget(normalized, policy); + return { + target, + identity: normalized, + matched: target === 'candidate', + }; +} diff --git a/release-gate/canary-routing.test.mjs b/release-gate/canary-routing.test.mjs new file mode 100644 index 0000000..a12d0f2 --- /dev/null +++ b/release-gate/canary-routing.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createCanaryPolicy, + describeCanaryMatch, + resolveCanaryTarget, +} from './canary-routing.mjs'; + +const policy = createCanaryPolicy({ + usernames: ['john'], + wechatUserIds: ['wx_ul610et8'], +}); + +test('canary routes the configured john identity to candidate', () => { + assert.equal(resolveCanaryTarget({ id: 'a6fb1e97', username: 'john' }, policy), 'candidate'); +}); + +test('canary routes the exact 唐 WeChat identity by immutable user id', () => { + assert.equal( + resolveCanaryTarget({ id: 'a70ff537', displayName: '唐', wechatUserId: 'wx_ul610et8' }, policy), + 'candidate', + ); +}); + +test('same nickname without the configured immutable identity stays on stable', () => { + assert.equal( + resolveCanaryTarget({ id: 'other-user', displayName: '唐', wechatUserId: 'wx_other' }, policy), + 'stable', + ); +}); + +test('unrecognized, missing, and malformed identities fail closed to stable', () => { + for (const identity of [null, {}, { username: 'admin' }, { id: 'wx_ul610et8' }]) { + assert.equal(resolveCanaryTarget(identity, policy), 'stable'); + } +}); + +test('match description never exposes display-name routing', () => { + const result = describeCanaryMatch({ id: 'other', displayName: '唐' }, policy); + assert.equal(result.target, 'stable'); + assert.equal(result.matched, false); + assert.equal(result.identity.username, ''); +}); diff --git a/release-gate/catalog.mjs b/release-gate/catalog.mjs new file mode 100644 index 0000000..b109da1 --- /dev/null +++ b/release-gate/catalog.mjs @@ -0,0 +1,157 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export const SCENARIO_GROUP_COUNTS = Object.freeze({ + REL: 11, + AUTH: 8, + CHAT: 15, + MEM: 16, + FILE: 8, + IMGPG: 8, + XLS: 12, + PAGE: 13, + DATA: 13, + SEARCH: 8, + AGENT: 11, + SCHED: 5, + WX: 13, + BILL: 7, + MS: 9, + PLAZA: 5, + CFG: 8, + UI: 8, + COMP: 9, +}); + +export const SCENARIO_MODE_BY_GROUP = Object.freeze({ + REL: 'upgrade', + AUTH: 'scenarios', + CHAT: 'scenarios', + MEM: 'scenarios', + FILE: 'scenarios', + IMGPG: 'scenarios', + XLS: 'scenarios', + PAGE: 'scenarios', + DATA: 'scenarios', + SEARCH: 'providers', + AGENT: 'scenarios', + SCHED: 'scenarios', + WX: 'scenarios', + BILL: 'scenarios', + MS: 'scenarios', + PLAZA: 'scenarios', + CFG: 'deterministic', + UI: 'browser', + COMP: 'upgrade', +}); + +const ALWAYS_REQUIRED = new Set([ + ...range('REL', 1, 11), + 'AUTH-01', + 'AUTH-04', + 'AUTH-05', + 'AUTH-06', + 'CHAT-01', + 'CHAT-02', + 'CHAT-06', + 'CHAT-07', + 'CHAT-08', + 'CHAT-09', + 'CHAT-11', + 'CHAT-12', + 'CHAT-13', + 'CHAT-15', + 'MEM-08', + 'MEM-13', + 'MEM-14', + 'MEM-15', + 'MEM-16', + 'FILE-07', + 'PAGE-08', + 'PAGE-09', + 'PAGE-10', + 'PAGE-12', + 'DATA-06', + 'DATA-10', + 'DATA-12', + 'AGENT-10', + 'AGENT-11', + ...range('WX', 9, 13), + 'BILL-02', + 'BILL-03', + 'BILL-04', + 'MS-01', + 'MS-03', + 'MS-04', + 'CFG-08', + ...range('COMP', 1, 4), + 'COMP-08', + 'COMP-09', +]); + +function range(prefix, start, end) { + return Array.from( + { length: end - start + 1 }, + (_, index) => `${prefix}-${String(start + index).padStart(2, '0')}`, + ); +} + +export function expectedScenarioIds() { + return Object.entries(SCENARIO_GROUP_COUNTS).flatMap( + ([prefix, count]) => range(prefix, 1, count), + ); +} + +export function isScenarioExemptable(id) { + return !ALWAYS_REQUIRED.has(id); +} + +export function validateScenarioCatalog(catalog) { + const expected = expectedScenarioIds(); + const expectedSet = new Set(expected); + const ids = catalog.map((scenario) => scenario.id); + const unique = new Set(ids); + const missing = expected.filter((id) => !unique.has(id)); + const unexpected = ids.filter((id) => !expectedSet.has(id)); + const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index); + + if (catalog.length !== expected.length || unique.size !== expected.length) { + throw new Error( + `Release gate catalog must contain exactly ${expected.length} unique scenarios ` + + `(found ${catalog.length}/${unique.size})`, + ); + } + if (missing.length || unexpected.length || duplicates.length) { + throw new Error( + `Release gate catalog mismatch: missing=${missing.join(',') || '-'} ` + + `unexpected=${unexpected.join(',') || '-'} duplicates=${[...new Set(duplicates)].join(',') || '-'}`, + ); + } + for (const scenario of catalog) { + if (!scenario.name || !scenario.group || !scenario.mode) { + throw new Error(`Scenario ${scenario.id} is missing name, group, or mode`); + } + } + return catalog; +} + +export async function loadScenarioCatalog({ + root = path.resolve(new URL('..', import.meta.url).pathname), + guardianPath = path.join(root, 'docs', 'production-release-guardian.md'), +} = {}) { + const source = await fs.readFile(guardianPath, 'utf8'); + const catalog = []; + const scenarioPattern = /^- \*\*([A-Z]+-\d{2})\*\*:(.+)$/gm; + for (const match of source.matchAll(scenarioPattern)) { + const id = match[1]; + const group = id.split('-')[0]; + catalog.push({ + id, + name: match[2].trim(), + group, + mode: SCENARIO_MODE_BY_GROUP[group], + exemptable: isScenarioExemptable(id), + }); + } + return validateScenarioCatalog(catalog); +} diff --git a/release-gate/catalog.test.mjs b/release-gate/catalog.test.mjs new file mode 100644 index 0000000..8752453 --- /dev/null +++ b/release-gate/catalog.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + expectedScenarioIds, + isScenarioExemptable, + loadScenarioCatalog, + SCENARIO_GROUP_COUNTS, +} from './catalog.mjs'; +import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs'; + +test('guardian catalog contains 187 unique contiguous scenario IDs', async () => { + const catalog = await loadScenarioCatalog(); + assert.equal(catalog.length, 187); + assert.equal(new Set(catalog.map((scenario) => scenario.id)).size, 187); + assert.deepEqual(catalog.map((scenario) => scenario.id), expectedScenarioIds()); + assert.equal(Object.values(SCENARIO_GROUP_COUNTS).reduce((sum, count) => sum + count, 0), 187); +}); + +test('non-exemptable scenarios match hard release protections', () => { + assert.equal(isScenarioExemptable('REL-01'), false); + assert.equal(isScenarioExemptable('COMP-09'), false); + assert.equal(isScenarioExemptable('MEM-14'), false); + assert.equal(isScenarioExemptable('SEARCH-04'), true); +}); + +test('automation suites reference unique catalog scenarios', async () => { + const catalog = await loadScenarioCatalog(); + assert.equal(validateAutomationSuites(catalog), AUTOMATION_SUITES); + const assigned = AUTOMATION_SUITES.flatMap((suite) => suite.scenarios); + assert.equal(new Set(assigned).size, assigned.length); +}); diff --git a/release-gate/changed-scope.test.mjs b/release-gate/changed-scope.test.mjs new file mode 100644 index 0000000..270b315 --- /dev/null +++ b/release-gate/changed-scope.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseGitStatusPorcelainZ } from '../scripts/git-porcelain-paths.mjs'; + +test('changed-scope parser preserves spaces and unicode without git status prefixes', () => { + const output = [ + ' M release-gate/report.mjs', + '?? docs/发包必看.md', + 'A docs/file with spaces.md', + '', + ].join('\0'); + assert.deepEqual(parseGitStatusPorcelainZ(output), [ + 'release-gate/report.mjs', + 'docs/发包必看.md', + 'docs/file with spaces.md', + ]); +}); + +test('changed-scope parser uses the rename destination and skips the source entry', () => { + assert.deepEqual( + parseGitStatusPorcelainZ('R docs/new.md\0docs/old.md\0 M package.json\0'), + ['docs/new.md', 'package.json'], + ); +}); diff --git a/release-gate/coverage.mjs b/release-gate/coverage.mjs new file mode 100644 index 0000000..02082f9 --- /dev/null +++ b/release-gate/coverage.mjs @@ -0,0 +1,1167 @@ +export const AUTOMATION_SUITES = Object.freeze([ + { + id: 'runtime-reproducibility', + mode: 'upgrade', + scenarios: ['REL-03'], + cases: { + 'REL-03': ['candidate plus two clean rebuilds produce the same sorted runtime tree SHA256'], + }, + command: [process.execPath, 'scripts/run-release-gate-artifact-repro.mjs'], + }, + { + id: 'runtime-public-url-policy', + mode: 'upgrade', + scenarios: ['REL-05'], + cases: { + 'REL-05': [ + 'built public HTML/CSS contains no loopback, private, or unknown TKMind public URL', + 'production entry defaults the canonical H5 public base to https://m.tkmind.cn', + ], + }, + command: [process.execPath, 'scripts/run-release-gate-public-url-scan.mjs'], + }, + { + id: 'runtime-production-homolog-cold-start', + mode: 'upgrade', + scenarios: ['REL-06'], + cases: { + 'REL-06': [ + 'packaged server cold-starts with NODE_ENV=production against unique isolated MySQL/PostgreSQL databases', + 'schema initialization creates the service tables, health responds, and a second start is idempotent', + ], + }, + command: [process.execPath, 'scripts/run-release-gate-runtime-cold-start.mjs'], + }, + { + id: 'runtime-sanitized-data-upgrade', + mode: 'upgrade', + scenarios: ['REL-07'], + cases: { + 'REL-07': [ + 'sanitized legacy MySQL user schema upgrades additively while preserving the row and password/plan defaults', + 'sanitized Page Data PostgreSQL fixture remains unchanged across two packaged-runtime starts', + ], + }, + command: [process.execPath, 'scripts/run-release-gate-runtime-upgrade.mjs'], + }, + { + id: 'runtime-linux-dependency-closure', + mode: 'upgrade', + scenarios: ['REL-11'], + cases: { + 'REL-11': [ + 'Linux ARM64 Node parses the Portal and WeChat bundles and loads the Agent Run worker', + 'sandbox, MindSearch, and Excel MCP modules start in a no-network container and answer initialize', + ], + }, + command: [process.execPath, 'scripts/run-release-gate-runtime-container.mjs'], + }, + { + id: 'local-auth-full-stack', + mode: 'scenarios', + scenarios: [ + 'AUTH-01', + 'AUTH-02', + 'AUTH-03', + 'AUTH-04', + 'AUTH-05', + 'AUTH-06', + 'FILE-07', + ], + cases: { + 'AUTH-01': ['local-stack: login returns authenticated user, Cookie, and authenticated status'], + 'AUTH-02': ['local-stack: five bad passwords return 401; next login is rate-limited with Retry-After'], + 'AUTH-03': ['local-stack: a valid persisted session survives Portal restart; an invented Cookie does not'], + 'AUTH-04': ['local-stack: logout revokes the old Cookie for a protected API'], + 'AUTH-05': [ + 'local-stack: a second user cannot read the first user session, attachment, or private page', + 'Page Data tests: owner mismatch, visitor roles, and own-row policies prevent private data crossover', + ], + 'AUTH-06': ['admin route test: an ordinary user gets 403 for reads and self-role promotion'], + 'FILE-07': [ + 'local-stack: a second user cannot download the first user completed attachment', + 'asset route tests: expired publication access and mismatched signed tokens fail closed', + ], + }, + command: [process.execPath, 'scripts/run-release-gate-auth-scenarios.mjs'], + }, + { + id: 'local-browser-smoke', + mode: 'browser', + scenarios: ['UI-01', 'UI-02', 'UI-03', 'UI-04', 'UI-05', 'UI-06', 'UI-07', 'UI-08'], + cases: { + 'UI-01': ['isolated browser: desktop login reaches the authenticated app with chat and history/navigation controls'], + 'UI-02': ['isolated browser: 390x844 mobile viewport exposes the stream-capable chat composer'], + 'UI-03': ['isolated browser: the real application exposes attachment upload controls'], + 'UI-04': ['isolated public-page replay: preview, edit, stable publish URL, and share entry work'], + 'UI-05': ['isolated public-page replay: form POST returns 201 and the submitted row is rendered'], + 'UI-06': ['isolated browser: login error, loading, disabled, retry/result states are visible'], + 'UI-07': ['isolated browser: refresh, back/forward, and app re-entry preserve authentication and navigation state'], + 'UI-08': ['isolated browser: zero console/page errors, named controls, and no horizontal mobile overflow'], + }, + command: [process.execPath, 'scripts/run-release-gate-browser-smoke.mjs'], + }, + { + id: 'chat-finish-merge', + mode: 'deterministic', + scenarios: ['CHAT-06'], + cases: { + 'CHAT-06': ['chat-finish-sync.test.mjs: Finish and UpdateConversation merge without message loss'], + }, + command: [process.execPath, '--test', 'chat-finish-sync.test.mjs'], + }, + { + id: 'memory-ingestion-safety', + mode: 'deterministic', + scenarios: ['MEM-13', 'MEM-14', 'MEM-15'], + cases: { + 'MEM-13': [ + 'memory shadow matrix rejects routing prompt, Memory Context, skill, tool, system, and assistant text', + ], + 'MEM-14': [ + 'one 1000-clause evidence message produces at most one candidate and the pending pool stays bounded', + ], + 'MEM-15': [ + 'exact and semantic preference repeats deduplicate to one candidate with source ID and evidence hash', + ], + }, + command: [process.execPath, '--test', 'memory-v2-personal-shadow.test.mjs'], + }, + { + id: 'page-public-materialization', + mode: 'deterministic', + scenarios: ['PAGE-08'], + cases: { + 'PAGE-08': ['mindspace-public-finish-sync.test.mjs: chained edit_file patches materialize to public HTML'], + }, + command: [process.execPath, '--test', 'mindspace-public-finish-sync.test.mjs'], + }, + { + id: 'page-content-delivery-scenarios', + mode: 'scenarios', + scenarios: ['PAGE-01', 'PAGE-02'], + cases: { + 'PAGE-01': [ + 'local scenario: a plain-text Suzhou request creates a new public HTML page, returns a MindSpace link, and responds HTTP 200 with the requested content', + ], + 'PAGE-02': [ + 'local scenario: structured long-form source content becomes a new mobile-readable public page retaining its sections, keywords, shareable link, and HTTP 200 delivery', + ], + }, + command: [process.execPath, 'scripts/run-release-gate-page-scenarios.mjs'], + }, + { + id: 'page-data-access-contract', + mode: 'deterministic', + scenarios: ['DATA-06', 'DATA-07', 'DATA-09', 'DATA-10'], + cases: { + 'DATA-06': ['page-data acceptance/public tests: anonymous insert is policy-gated; admin data remains protected'], + 'DATA-07': ['page-data route/public tests: CRUD, validation, export, restore, and closed-dataset behavior'], + 'DATA-09': [ + 'Page Data abuse tests: captcha is required and verified, insert rate limits fail with 429, and one idempotency key produces exactly one row under concurrent replay', + ], + 'DATA-10': ['page-data acceptance/routes tests: dataset registration, page policy, and workspace binding agree'], + }, + command: [ + process.execPath, + '--test', + 'page-data-acceptance.test.mjs', + 'page-data-browser-client.test.mjs', + 'page-data-captcha.test.mjs', + 'page-data-public-service.test.mjs', + 'page-data-routes.test.mjs', + 'page-data-session-store.test.mjs', + ], + }, + { + id: 'page-data-product-scenarios', + mode: 'scenarios', + scenarios: ['DATA-01', 'DATA-02', 'DATA-03', 'DATA-04'], + cases: { + 'DATA-01': [ + 'local scenario: AI usage survey creates a fresh questionnaire and admin page, registers a dataset/policy, and returns both delivery links', + ], + 'DATA-02': [ + 'local scenario: customer ordering creates a writable storefront plus authenticated management/statistics/export page backed by Page Data', + ], + 'DATA-03': [ + 'local scenario: supplier reporting creates validated submission and management pages backed by a registered private dataset', + ], + 'DATA-04': [ + 'local scenario: TKMind feature preference survey delivers questionnaire/admin pages, Page Data policy, dataset, and two public links', + ], + }, + command: [process.execPath, 'scripts/run-release-gate-page-data-scenarios.mjs'], + }, + { + id: 'wechat-native-image-delivery', + mode: 'deterministic', + scenarios: ['WX-05'], + cases: { + 'WX-05': ['wechat-media.test.mjs: generated image is converted, origin-checked, and uploaded as native media'], + }, + command: [process.execPath, '--test', 'wechat-media.test.mjs'], + }, + { + id: 'mindspace-page-sync', + mode: 'deterministic', + scenarios: ['DATA-11', 'MS-04'], + cases: { + 'DATA-11': ['mindspace page sync tests: remote Page Data pages sync and storage misses fall back to workspace'], + 'MS-04': ['mindspace page sync tests: remote sync, thumbnail generation, and workspace fallback'], + }, + command: [ + process.execPath, + '--test', + 'mindspace-pages.test.mjs', + 'mindspace-page-sync-service.test.mjs', + ], + }, + { + id: 'access-policy-modes', + mode: 'deterministic', + scenarios: ['AUTH-07'], + cases: { + 'AUTH-07': [ + 'portal access policy tests: off, per-group enforce, master enforcement, and kill switch', + ], + }, + command: [ + process.execPath, + '--test', + 'server/portal-access-policy.test.mjs', + 'server/portal-api-auth-middleware.test.mjs', + ], + }, + { + id: 'security-input-boundaries', + mode: 'deterministic', + scenarios: ['AUTH-08'], + cases: { + 'AUTH-08': [ + 'asset tests: traversal filenames and script payloads are rejected', + 'Page Data tests: SQL-like keys and unauthorized columns are rejected', + 'content/CSP tests: unsafe scripts and browser persistence are blocked', + 'admin configuration tests: provider secrets are masked', + ], + }, + command: [ + process.execPath, + '--test', + 'mindspace-assets.test.mjs', + 'mindspace-content-scan.test.mjs', + 'mindspace-published-page-csp.test.mjs', + 'page-data-acceptance.test.mjs', + 'page-data-integration.test.mjs', + 'memory-v2-admin-config.test.mjs', + ], + }, + { + id: 'chat-routing-contract', + mode: 'deterministic', + scenarios: ['CHAT-01', 'CHAT-02', 'CHAT-03'], + cases: { + 'CHAT-01': ['chat router tests: ordinary greetings select Direct Chat and produce a user-facing reply'], + 'CHAT-02': ['chat router tests: page, Page Data, file, and tool intents select Agent execution'], + 'CHAT-03': ['chat router tests: realtime/news questions force the web capability instead of Direct Chat'], + }, + command: [ + process.execPath, + '--test', + 'chat-intent-router.test.mjs', + 'direct-chat-service.test.mjs', + 'chat-skills.test.mjs', + ], + }, + { + id: 'chat-session-continuity', + mode: 'deterministic', + scenarios: ['CHAT-04', 'CHAT-05'], + cases: { + 'CHAT-04': ['router and broker tests: continuation/confirmation retain the active Agent session'], + 'CHAT-05': ['session broker tests: explicit new sessions and eligible session reuse follow ownership rules'], + }, + command: [ + process.execPath, + '--test', + 'chat-intent-router.test.mjs', + 'session-broker.test.mjs', + 'session-reconcile.test.mjs', + ], + }, + { + id: 'chat-stream-resume', + mode: 'deterministic', + scenarios: ['CHAT-07', 'CHAT-08'], + cases: { + 'CHAT-07': ['session stream tests: Portal cursor replay resumes after disconnect without gaps or duplicates'], + 'CHAT-08': ['session stream tests: Portal replay IDs never become Goose Last-Event-ID values'], + }, + command: [ + process.execPath, + '--test', + 'session-stream.test.mjs', + 'session-stream-store.test.mjs', + 'agent-run-stream.test.mjs', + ], + }, + { + id: 'chat-run-failure-contract', + mode: 'deterministic', + scenarios: ['CHAT-10', 'CHAT-14'], + cases: { + 'CHAT-10': ['Agent Run tests: idempotency, retry, cancel, timeout, and poisoned tool history recovery'], + 'CHAT-14': ['conversation display/router tests: current user correction wins over hidden routing and skill text'], + }, + command: [ + process.execPath, + '--test', + 'agent-run-gateway.test.mjs', + 'agent-run-routes.test.mjs', + 'conversation-display.test.mjs', + 'chat-finish-sync.test.mjs', + 'message-stream.test.mjs', + ], + }, + { + id: 'chat-terminal-recovery-and-persistence', + mode: 'deterministic', + scenarios: ['CHAT-09', 'CHAT-11', 'CHAT-13', 'CHAT-15'], + cases: { + 'CHAT-09': [ + 'Agent wait tests: unavailable SSE and transient status errors still poll to terminal and restore final messages', + ], + 'CHAT-11': [ + 'active-run gate tests: streaming/loading/connecting/waiting submissions are blocked before request creation', + 'Agent Run route tests: active conflicts and stale recovery return bounded user-facing outcomes', + ], + 'CHAT-13': [ + 'Agent Run tests: final snapshot refresh, normalized transcript persistence, and exact row count precede success', + ], + 'CHAT-15': [ + 'canonical transcript tests: single-character visible user text is persisted in order and incomplete snapshots fail closed', + ], + }, + command: [ + process.execPath, + '--test', + 'agent-run-wait.test.mjs', + 'chat-agent-run-gate.test.mjs', + 'agent-run-gateway.test.mjs', + 'agent-run-routes.test.mjs', + 'conversation-transcript-persist.test.mjs', + ], + }, + { + id: 'legacy-chat-message-contract', + mode: 'deterministic', + scenarios: ['CHAT-12'], + cases: { + 'CHAT-12': [ + 'goose message tests: missing legacy userVisible/agentVisible and optional metadata are normalized', + 'session wait tests: non-UUID request IDs remain scoped through Message and Finish', + 'conversation repair tests: damaged/placeholder history restores visible legacy messages', + 'Agent/WeChat recovery tests: malformed tool history is repaired without exposing the raw failure', + ], + }, + command: [ + process.execPath, + '--test', + 'goose-message.test.mjs', + 'session-reply-wait.test.mjs', + 'conversation-repair.test.mjs', + 'conversation-transcript-persist.test.mjs', + 'wechat-mp.test.mjs', + ], + }, + { + id: 'memory-save-recall-lifecycle', + mode: 'deterministic', + scenarios: [ + 'MEM-01', + 'MEM-02', + 'MEM-03', + 'MEM-04', + 'MEM-05', + 'MEM-06', + 'MEM-07', + 'MEM-08', + 'MEM-09', + 'MEM-10', + 'MEM-11', + 'MEM-12', + 'MEM-16', + ], + cases: { + 'MEM-01': ['personal memory tests: explicit remember requests auto-accept and persist evidence-backed memory'], + 'MEM-02': ['user memory profile tests: stored memories are injected into a newly built session context'], + 'MEM-03': ['pgvector tests: semantic/hybrid retrieval recalls a paraphrased Chinese memory'], + 'MEM-04': ['episodic tests: historical conversation topic is resolved with same-user evidence'], + 'MEM-05': [ + 'conversation memory tests: a replacement preference archives the conflicting old value before recall', + 'suppression tests: archived preference text is blocked from legacy and semantic fallback injection', + ], + 'MEM-06': ['lifecycle tests: user-scoped forget removes only the selected memory'], + 'MEM-07': ['episodic tests: ordinary unrelated chat does not trigger episodic retrieval'], + 'MEM-08': ['episodic/lifecycle tests: every resolve, list, and forget operation is user scoped'], + 'MEM-09': ['personal memory policy tests: sensitive candidates are rejected and deletions are user scoped'], + 'MEM-10': ['candidate/lifecycle tests: confirmation, promotion, expiry, and rollout scope are enforced'], + 'MEM-11': ['Memory V2 runtime tests: unavailable semantic providers fall back to legacy memory'], + 'MEM-12': ['memory context tests: snapshot and router payloads are bounded with evidence/source retained'], + 'MEM-16': [ + 'deletion suppression tests: durable user tombstones block legacy, candidate, context, episodic index, and snapshot fallback re-ingestion', + ], + }, + command: [ + process.execPath, + '--test', + 'conversation-memory.test.mjs', + 'user-memory-profile.test.mjs', + 'episodic-memory.test.mjs', + 'memory-v2.test.mjs', + 'memory-v2-runtime.test.mjs', + 'memory-v2-pgvector.test.mjs', + 'memory-v2-lifecycle.test.mjs', + 'memory-v2-personal-store.test.mjs', + 'memory-v2-personal-shadow.test.mjs', + 'memory-v2-suppression.test.mjs', + ], + }, + { + id: 'file-rejection-and-delivery', + mode: 'deterministic', + scenarios: ['FILE-06'], + cases: { + 'FILE-06': ['asset tests: traversal, script payload, MIME, size, and unsupported file rules fail closed'], + }, + command: [ + process.execPath, + '--test', + 'mindspace-assets.test.mjs', + 'mindspace-public-asset-token.test.mjs', + 'mindspace-html-download-links.test.mjs', + 'mindspace-chat-docx-package.test.mjs', + 'wechat-media.test.mjs', + ], + }, + { + id: 'file-upload-selection-and-extraction', + mode: 'deterministic', + scenarios: ['FILE-01', 'FILE-02', 'FILE-03', 'FILE-04', 'FILE-05'], + cases: { + 'FILE-01': [ + 'asset tests: image upload is scanned, normalized, previewed, session-associated, and read by vision in order', + ], + 'FILE-02': [ + 'asset/Excel tests: an XLSX is uploaded, materialized, selected, parsed, and routed to the dedicated analyzer', + ], + 'FILE-03': [ + 'attachment tests: UTF-8 BOM and GB18030 CSV preserve Chinese fields, quoted delimiters, and empty values', + ], + 'FILE-04': [ + 'attachment tests: PDF and DOCX text plus filename/MIME metadata are extracted and previewed', + ], + 'FILE-05': [ + 'attachment tests: current-turn multiple-file order is stable, cancelled selections are excluded, and reused files do not leak prior turn state', + ], + }, + command: [ + process.execPath, + '--test', + 'mindspace-assets.test.mjs', + 'mindspace-asset-preview.test.mjs', + 'mindspace-asset-agent.test.mjs', + 'mindspace-chat-context.test.mjs', + 'mindspace-attachment-text.test.mjs', + 'tkmind-proxy-attachment.test.mjs', + 'tkmind-proxy-vision.test.mjs', + 'excel-analyst.test.mjs', + ], + }, + { + id: 'excel-analysis-verified-report', + mode: 'deterministic', + scenarios: ['XLS-01', 'XLS-02', 'XLS-03', 'XLS-04', 'XLS-05', 'XLS-07', 'XLS-10'], + cases: { + 'XLS-01': ['Excel engine test: a single sheet yields grouped metrics, chart, and accessible HTML report'], + 'XLS-02': ['Excel engine test: sales and master-data sheets join to fixture-defined results'], + 'XLS-03': ['Excel engine test: formulas, dates, totals, profit, and margin equal fixture answers'], + 'XLS-04': ['Excel engine test: merged headers, blank rows, hidden sheets, and incomplete master data are bounded'], + 'XLS-05': ['Excel engine test: every report KPI/chart is generated from the source workbook model and validated'], + 'XLS-07': [ + 'Excel engine test: XLSX succeeds while XLS and CSV fail with the explicit Phase 1 supported-format contract', + ], + 'XLS-10': [ + 'Excel engine test: source workbooks remain private and absolute, traversal, symlink, and cross-user paths fail closed', + ], + }, + command: [process.execPath, '--test', 'excel-analyst.test.mjs'], + }, + { + id: 'page-image-policy-and-repair', + mode: 'deterministic', + scenarios: ['PAGE-03', 'PAGE-04', 'PAGE-05', 'PAGE-06', 'PAGE-07', 'PAGE-13'], + cases: { + 'PAGE-03': ['image policy tests: visual page intent independently decides body image generation'], + 'PAGE-04': ['image generation tests: required image output is reviewed and stored before success'], + 'PAGE-05': ['image policy tests: explicit no-image request disables body image generation'], + 'PAGE-06': ['image generation tests: provider/review failure returns explicit failure and stores no stale image'], + 'PAGE-07': ['image generation tests: purpose/idempotency keys reuse only the permitted hero/cover asset'], + 'PAGE-13': [ + 'router test: standalone image intent selects image-generation and explicitly forbids HTML/H5/page creation', + 'image service test: a replacement request uses a new current-request idempotency key, job, asset, and bitmap path', + ], + }, + command: [ + process.execPath, + '--test', + 'mindspace-image-generation.test.mjs', + 'mindspace-image-review.test.mjs', + 'image-make-client.test.mjs', + 'image-make-admin-config.test.mjs', + 'wechat/image-generation-policy.test.mjs', + 'mindspace-h5-html-finish-guard.test.mjs', + 'chat-intent-router.test.mjs', + ], + }, + { + id: 'page-scope-and-public-link-contract', + mode: 'deterministic', + scenarios: ['PAGE-09', 'PAGE-10'], + cases: { + 'PAGE-09': ['public HTML scope tests: a multi-page run updates only paths touched by the current request'], + 'PAGE-10': ['canonical/CSP/public link tests: filename conflicts and domain/link normalization remain valid'], + }, + command: [ + process.execPath, + '--test', + 'mindspace-run-public-html-scope.test.mjs', + 'mindspace-canonical-url.test.mjs', + 'mindspace-published-page-csp.test.mjs', + 'mindspace-public-links.test.mjs', + 'mindspace-public-route.test.mjs', + ], + }, + { + id: 'page-bounded-delivery-repair', + mode: 'deterministic', + scenarios: ['PAGE-12'], + cases: { + 'PAGE-12': [ + 'H5 finish guard tests: incomplete public HTML/assets trigger exactly one repair and a second incomplete Finish reaches the limit', + 'Agent Run tests: missing current public HTML or Page Data deliverables produce a failed terminal state', + ], + }, + command: [ + process.execPath, + '--test', + 'mindspace-h5-html-finish-guard.test.mjs', + 'agent-run-gateway.test.mjs', + 'agent-run-deliverable-check.test.mjs', + 'server/portal-session-routes.test.mjs', + ], + }, + { + id: 'page-data-policy-hardening', + mode: 'deterministic', + scenarios: ['DATA-08', 'DATA-12'], + cases: { + 'DATA-08': ['Page Data policy tests: updateRow/deleteRow HTML usage derives matching authenticated permissions'], + 'DATA-12': ['Page Data integration tests: cross-user, SQL-like fields, unauthorized columns, and audit logs'], + }, + command: [ + process.execPath, + '--test', + 'page-data-policy-index.test.mjs', + 'page-data-browser-client.test.mjs', + 'page-data-integration.test.mjs', + 'page-data-ops.test.mjs', + ], + }, + { + id: 'search-provider-boundaries', + mode: 'deterministic', + scenarios: [ + 'SEARCH-01', + 'SEARCH-02', + 'SEARCH-03', + 'SEARCH-04', + 'SEARCH-05', + 'SEARCH-06', + 'SEARCH-07', + 'SEARCH-08', + ], + cases: { + 'SEARCH-01': [ + 'Deep Search research test: the search provider is called across multiple rounds and the completed report retains numbered, canonical, verifiable sources', + ], + 'SEARCH-02': ['Deep Search tests: SearXNG results are queried, normalized, and bounded'], + 'SEARCH-03': ['Deep Search tests: Reader extracts public HTML and rejects private/failed destinations'], + 'SEARCH-04': ['MindSearch tests: GitHub search is capability/config gated and token-isolated'], + 'SEARCH-05': [ + 'Deep Search LLM test: the selected provider/model drives planning and citation-safe synthesis through the authenticated Portal gateway without exposing provider secrets', + ], + 'SEARCH-06': [ + 'Deep Search HTTP test: health remains public, but a mismatched service secret returns explicit 401 UNAUTHORIZED and cannot start research', + ], + 'SEARCH-07': ['Deep Search tests: provider cancellation and failure handling are bounded'], + 'SEARCH-08': ['capability tests: loopback search endpoints are rewritten for container host gateway access'], + }, + command: [ + process.execPath, + '--test', + 'deep-search.test.mjs', + 'mindsearch.test.mjs', + 'capabilities.test.mjs', + ], + }, + { + id: 'agent-worker-safety', + mode: 'deterministic', + scenarios: [ + 'AGENT-01', + 'AGENT-02', + 'AGENT-03', + 'AGENT-04', + 'AGENT-05', + 'AGENT-06', + 'AGENT-07', + 'AGENT-08', + 'AGENT-09', + ], + cases: { + 'AGENT-01': ['Agent Run tests: an ordinary run starts a goosed session and reaches succeeded'], + 'AGENT-02': ['Agent Run tests: queue concurrency and session affinity are enforced'], + 'AGENT-03': [ + 'worker identity tests: Portal requirements and claimed worker runtime root/build ID normalize and match', + ], + 'AGENT-04': ['worker identity tests: required identity mismatch fails closed'], + 'AGENT-05': ['Agent Run/Executor tests: request idempotency, retries, and duplicate claims are fenced'], + 'AGENT-06': ['worker protocol tests: expired leases and stale runs recover or reach one terminal failure'], + 'AGENT-07': ['Agent Run tests: allowlisted Aider users can execute validated code tasks'], + 'AGENT-08': ['Agent Run tests: unapproved users and task types are rejected before run creation'], + 'AGENT-09': ['tool/executor tests: artifact paths and workspace aliases cannot escape the user workspace'], + }, + command: [ + process.execPath, + '--test', + 'agent-run-gateway.test.mjs', + 'agent-run-routes.test.mjs', + 'agent-run-deliverable-check.test.mjs', + 'tool-gateway.test.mjs', + 'services/orchestrator/executor-worker-protocol.test.mjs', + 'services/orchestrator/executor-worker-runtime.test.mjs', + 'services/orchestrator/executor-admission-policy.test.mjs', + 'services/orchestrator/executor-adapters.test.mjs', + ], + }, + { + id: 'agent-extension-compatibility', + mode: 'deterministic', + scenarios: ['AGENT-10'], + cases: { + 'AGENT-10': [ + 'capability tests: MySQL/PostgreSQL endpoints and bundled MCP module paths resolve for the target runtime', + 'PostgreSQL tests: SQLite schema conversion and SET-capable tenant roles are reconciled before access', + 'extension fallback test: private-data configuration failure disables only Page Data tools while non-data sandbox tools remain available', + ], + }, + command: [ + process.execPath, + '--test', + 'capabilities.test.mjs', + 'mindspace-userdata-postgres.test.mjs', + 'postgres-user-data-space-service.test.mjs', + 'mindspace-sandbox-mcp.test.mjs', + ], + }, + { + id: 'agent-exactly-once-terminal-recovery', + mode: 'deterministic', + scenarios: ['AGENT-11'], + cases: { + 'AGENT-11': [ + 'Agent Run tests: timeout and retry exhaustion each append exactly one terminal event', + 'stale heartbeat/worker recovery is fenced and a second recovery cannot create another terminal result', + 'deliverable-based recovery preserves one user-visible artifact and synchronizes it once', + ], + }, + command: [ + process.execPath, + '--test', + 'agent-run-gateway.test.mjs', + 'agent-run-deliverable-check.test.mjs', + 'services/orchestrator/executor-worker-protocol.test.mjs', + ], + }, + { + id: 'schedule-time-and-delivery', + mode: 'deterministic', + scenarios: ['SCHED-01', 'SCHED-02', 'SCHED-03', 'SCHED-04', 'SCHED-05'], + cases: { + 'SCHED-01': ['schedule service tests: wall-clock input resolves in the configured user timezone'], + 'SCHED-02': ['schedule intent tests: missing or ambiguous times require clarification'], + 'SCHED-03': ['schedule service tests: cancel and bulk delete update only the user reminder'], + 'SCHED-04': [ + 'worker tests: a failed delivery retries, reaches one success, and cannot send again after sent state', + ], + 'SCHED-05': [ + 'worker restart test: shared persisted pending state survives process replacement and sends once', + ], + }, + command: [ + process.execPath, + '--test', + 'schedule-intent.test.mjs', + 'schedule-service.test.mjs', + 'schedule-reminder-worker.test.mjs', + ], + }, + { + id: 'wechat-channel-contract', + mode: 'deterministic', + scenarios: [ + 'WX-01', + 'WX-02', + 'WX-03', + 'WX-04', + 'WX-06', + 'WX-07', + 'WX-08', + 'FILE-08', + ], + cases: { + 'WX-01': ['WeChat MP tests: plaintext/AES signatures and connectivity challenge validate'], + 'WX-02': ['WeChat MP tests: duplicate message IDs are ignored'], + 'WX-03': ['WeChat MP tests: openid binding and dedicated user session isolation are preserved'], + 'WX-04': ['WeChat MP tests: page output resolves to the canonical public link'], + 'WX-06': ['WeChat MP tests: current-run thumbnail is required and one unambiguous missing asset is repaired'], + 'WX-07': ['WeChat OAuth tests: callback configuration, state, and return URL reject external redirects'], + 'WX-08': ['WeChat Pay tests: sandbox callback signature and message/channel boundaries validate'], + 'FILE-08': [ + 'DOCX package tests: generated bytes are persisted, registered in the manifest, and exposed only through the canonical artifact download URL', + 'download-link and asset-route tests: page buttons resolve to an existing sibling or asset API; authenticated, signed-public, expired, and denied access paths are enforced', + 'WeChat tests: Word/Excel media is persisted into the user public area with H5 attachment metadata, and Word page tasks require the real DOCX artifact before HTML delivery', + ], + }, + command: [ + process.execPath, + '--test', + 'wechat-mp.test.mjs', + 'wechat-oauth.test.mjs', + 'wechat-pay.test.mjs', + 'wechat-media.test.mjs', + 'wechat/wechat-channel.test.mjs', + 'wechat/image-generation-policy.test.mjs', + 'mindspace-chat-docx-package.test.mjs', + 'mindspace-html-download-links.test.mjs', + 'mindspace-public-asset-token.test.mjs', + 'server/portal-mindspace-asset-routes.test.mjs', + ], + }, + { + id: 'wechat-terminal-and-atomic-delivery', + mode: 'deterministic', + scenarios: ['WX-09', 'WX-10', 'WX-11', 'WX-12', 'WX-13'], + cases: { + 'WX-09': [ + 'WeChat ledger tests: duplicates stay fenced, stale processing is reclaimed, and accepted work reaches done or failed', + ], + 'WX-10': [ + 'image turn tests: historical image_url content is scrubbed before text follow-up and an unsupported update rotates the session', + ], + 'WX-11': [ + 'WeChat delivery tests: 45047/45015 persist one deferred result and claim it once on the next passive response', + ], + 'WX-12': [ + 'voice tests: AMR media reaches ASR and conversion/recognition failures return an explicit retry message without Agent execution', + ], + 'WX-13': [ + 'page delivery tests: real HTML, fresh current-run raster cover, description, platform brand, and verified public URL are required before send', + ], + }, + command: [ + process.execPath, + '--test', + 'user-auth.test.mjs', + 'wechat-mp.test.mjs', + 'wechat-media.test.mjs', + 'wechat/wechat-channel.test.mjs', + 'chat-image-turn-scope.test.mjs', + 'tkmind-proxy.test.mjs', + ], + }, + { + id: 'billing-plan-and-idempotency', + mode: 'deterministic', + scenarios: ['BILL-01', 'BILL-04', 'BILL-05'], + cases: { + 'BILL-01': ['subscription tests: free, paid, unlimited, and administrator entitlements are calculated'], + 'BILL-04': ['billing concurrency tests: Finish/SSE replay with the same request is charged once'], + 'BILL-05': ['billing concurrency tests: concurrent Finish settlement atomically deducts one charge'], + }, + command: [ + process.execPath, + '--test', + 'billing.test.mjs', + 'billing-token-state.test.mjs', + 'billing-subscription.test.mjs', + 'billing-session-concurrency.test.mjs', + ], + }, + { + id: 'billing-execution-terminal-contract', + mode: 'deterministic', + scenarios: ['BILL-02', 'BILL-03'], + cases: { + 'BILL-02': [ + 'Direct Chat and Agent Finish tests: each successful execution enters settlement once with the stable request ID', + 'billing state tests: repeated and concurrent settlement attempts charge once', + ], + 'BILL-03': [ + 'Direct/Agent tests: provider failure, cancellation, and a truly hanging SSE timeout never enter billing', + ], + }, + command: [ + process.execPath, + '--test', + 'direct-chat-service.test.mjs', + 'tkmind-proxy.test.mjs', + 'session-reply-wait.test.mjs', + 'billing-session-concurrency.test.mjs', + ], + }, + { + id: 'billing-recharge-and-admission-contract', + mode: 'deterministic', + scenarios: ['BILL-06', 'BILL-07'], + cases: { + 'BILL-06': [ + 'recharge service tests: forged WeChat callback signatures fail before credit, and a signed callback plus replay credits exactly once', + ], + 'BILL-07': [ + 'Portal Agent route test: an insufficient-balance response returns 402 before the run handler and leaves no residual task', + ], + }, + command: [ + process.execPath, + '--test', + 'billing-recharge.test.mjs', + 'wechat-pay.test.mjs', + 'server/portal-webhook-routes.test.mjs', + 'server/portal-agent-runtime-routes.test.mjs', + ], + }, + { + id: 'mindspace-delivery-contract', + mode: 'deterministic', + scenarios: ['MS-01', 'MS-02', 'MS-03', 'MS-05', 'MS-06', 'MS-07', 'MS-08'], + cases: { + 'MS-01': ['MindSpace service tests: health and versioned contract endpoints respond with the contract'], + 'MS-02': ['MindSpace adapter tests: local and remote adapters satisfy the same server contract'], + 'MS-03': ['public delivery tests: canonical, OG, sharing, and access behavior are stable'], + 'MS-05': ['conversation package tests: create, verify, registry, and historical backfill are consistent'], + 'MS-06': ['publication/download tests: DOCX and long-image companions exist and use non-local canonical links'], + 'MS-07': ['content/CSP tests: scan and localization reject dangerous scripts while retaining safe pages'], + 'MS-08': ['cleanup/purge tests: page and conversation deletion removes linked resources without orphans'], + }, + command: [ + process.execPath, + '--test', + 'mindspace-service/mindspace-rpc-server.test.mjs', + 'mindspace-local-server-adapter.test.mjs', + 'mindspace-remote-server-adapter.test.mjs', + 'mindspace-server-adapter.test.mjs', + 'mindspace-public-delivery.test.mjs', + 'mindspace-publications.test.mjs', + 'mindspace-conversation-package.test.mjs', + 'mindspace-conversation-package-backfill.test.mjs', + 'mindspace-conversation-package-verify.test.mjs', + 'mindspace-html-download-links.test.mjs', + 'mindspace-content-scan.test.mjs', + 'mindspace-published-page-csp.test.mjs', + 'mindspace-cleanup.test.mjs', + 'mindspace-page-purge.test.mjs', + ], + }, + { + id: 'plaza-publication-contract', + mode: 'deterministic', + scenarios: ['PLAZA-01', 'PLAZA-02', 'PLAZA-03', 'PLAZA-04', 'PLAZA-05'], + cases: { + 'PLAZA-01': ['Plaza integration tests: publish, moderate, and feed visibility preserve page content/link'], + 'PLAZA-02': ['Plaza post/SEO tests: discovery, search metadata, cover, and public URL normalize'], + 'PLAZA-03': ['Plaza interaction tests: reaction types and moderation/role permissions are enforced'], + 'PLAZA-04': ['Plaza algorithm tests: fixed fixtures yield stable hot score, MMR, cursor, and cold start order'], + 'PLAZA-05': [ + 'Plaza public/feed/recommendation tests: hidden posts and offline, private, or deleted source pages are inaccessible and invalidate stale feed caches', + ], + }, + command: [ + process.execPath, + '--test', + 'plaza-integration.test.mjs', + 'plaza-posts.test.mjs', + 'plaza-interactions.test.mjs', + 'plaza-ops.test.mjs', + 'plaza-seo.test.mjs', + 'plaza-algorithm.test.mjs', + 'plaza-recommend.test.mjs', + 'plaza-public.test.mjs', + ], + }, + { + id: 'configuration-observability-contract', + mode: 'deterministic', + scenarios: ['CFG-01', 'CFG-02', 'CFG-03', 'CFG-04', 'CFG-05', 'CFG-06', 'CFG-07'], + cases: { + 'CFG-01': ['Orchestrator config/bootstrap tests: disabled mode is not a Portal startup/request dependency'], + 'CFG-02': ['shadow dispatcher/observer tests: Shadow failures never change the Native user result'], + 'CFG-03': ['Orchestrator/access policy tests: emergency kill switches stop controlled execution immediately'], + 'CFG-04': ['analytics tests: disabled mode emits nothing; enabled events use bounded pseudonymous context'], + 'CFG-05': ['system disclosure tests: shadow records only, enforce refuses before execution'], + 'CFG-06': ['configuration migration tests: env-to-database setup is versioned and idempotent'], + 'CFG-07': ['observability tests: logs, metrics, and audit are traceable and exclude raw identity/secrets'], + }, + command: [ + process.execPath, + '--test', + 'services/orchestrator/admin-config.test.mjs', + 'services/orchestrator/shadow-dispatcher.test.mjs', + 'services/orchestrator/shadow-observer.test.mjs', + 'services/orchestrator/observability.test.mjs', + 'mindspace-analytics.test.mjs', + 'mindspace-rybbit.test.mjs', + 'system-disclosure-policy.test.mjs', + ], + }, + { + id: 'provider-model-catalog-compatibility', + mode: 'deterministic', + scenarios: ['CFG-08'], + cases: { + 'CFG-08': [ + 'Provider tests: every model stored in an active custom database profile is accepted', + 'Provider tests: a builtin legacy model removed from the current catalog is rejected before selection or sync', + ], + }, + command: [process.execPath, '--test', 'llm-providers.test.mjs'], + }, + { + id: 'release-goosed-runtime-identity', + mode: 'upgrade', + scenarios: ['REL-08'], + cases: { + 'REL-08': [ + 'release contract: exactly nine goosed containers are recreated after live swap, then ports, health, MCP paths, runtime identity, Portal, worker, and tunnel are checked', + ], + }, + command: [process.execPath, 'scripts/check-release-runtime-safety.mjs', 'REL-08'], + }, + { + id: 'release-rollback-restoration', + mode: 'upgrade', + scenarios: ['REL-09'], + cases: { + 'REL-09': [ + 'release contract: post-swap failure must archive the failed new live, restore old live, and restart the previous Portal', + ], + }, + command: [process.execPath, 'scripts/check-release-runtime-safety.mjs', 'REL-09'], + }, + { + id: 'release-drain-backup-and-pre-swap-safety', + mode: 'upgrade', + scenarios: ['REL-10'], + cases: { + 'REL-10': [ + 'release contract: block new Agent runs, drain active work within a bound, checksum and back up, then recheck zero active runs immediately before swap', + ], + }, + command: [ + process.execPath, + '--test', + 'release-gate/canary-routing.test.mjs', + 'release-gate/release-runtime-safety.test.mjs', + ], + }, + { + id: 'image-to-page-recorded-replay', + mode: 'deterministic', + scenarios: [ + 'IMGPG-01', + 'IMGPG-02', + 'IMGPG-03', + 'IMGPG-04', + 'IMGPG-05', + 'IMGPG-06', + 'IMGPG-07', + 'IMGPG-08', + 'PAGE-11', + ], + cases: { + 'IMGPG-01': ['recorded vision/tool replay materializes a responsive single-image-capable HTML page'], + 'IMGPG-02': ['recorded Qwen vision/OCR result is retained in the structurally valid page artifact'], + 'IMGPG-03': ['two current-turn image inputs and generated figure references preserve user order'], + 'IMGPG-04': ['recorded image facts plus text requirements jointly determine copy, color, and responsive layout'], + 'IMGPG-05': ['artifact uses relative uploaded image paths and rejects host filesystem, loopback, and temporary URLs'], + 'IMGPG-06': ['delivery injects OG/share metadata with the public image and stable canonical page URL'], + 'IMGPG-07': ['edit_file replay changes copy in place while preserving page path and original image references'], + 'IMGPG-08': ['403/unreadable image cannot call vision, bill usage, or invent recognition text'], + 'PAGE-11': ['daily page fixture requires news, weather, market, knowledge, authoritative HTTPS citations, compliance notice, viewport, and HTML artifact'], + }, + command: [ + process.execPath, + '--test', + 'release-gate/image-page-replay.test.mjs', + 'mindspace-thumbnails.test.mjs', + 'mindspace-og-tags.test.mjs', + ], + }, + { + id: 'excel-continuity-and-fallback', + mode: 'deterministic', + scenarios: ['XLS-06', 'XLS-08', 'XLS-09', 'XLS-11', 'XLS-12'], + cases: { + 'XLS-06': ['file, scan-cell, sample-row, and memory-facing ZIP budgets reject oversized input before unbounded work'], + 'XLS-08': ['a follow-up changes group dimension while reusing the exact same uploaded workbook path'], + 'XLS-09': ['report names the source workbook and renders a validated asset download URL'], + 'XLS-11': ['an approximately 5MB multi-sheet input crosses a bounded tool budget; fallback contains only preverified totals and a real artifact'], + 'XLS-12': ['updated workbook regenerates the same dashboard path while retaining old rows, confirmed title, and dimensions'], + }, + command: [process.execPath, '--test', 'release-gate/excel-continuity.test.mjs'], + }, + { + id: 'page-data-schema-evolution', + mode: 'deterministic', + scenarios: ['DATA-05', 'DATA-13'], + cases: { + 'DATA-05': ['children survey adds diet additively; historical hobby row remains and new diet row is writable'], + 'DATA-13': [ + 'incomplete/readonly dataset fails closed, then additive fields and actions rebind without losing the historical row or duplicating created_at', + 'legacy API and browser storage are rejected; soft-delete policy is derived only after matching schema registration', + ], + }, + command: [ + process.execPath, + '--test', + 'release-gate/page-data-evolution.test.mjs', + 'mindspace-page-data-finish-guard.test.mjs', + 'page-data-workspace-bind.test.mjs', + 'mindspace-pages.test.mjs', + ], + }, + { + id: 'offline-document-native-delivery', + mode: 'deterministic', + scenarios: ['MS-09'], + cases: { + 'MS-09': [ + 'stdlib DOCX generator has no network/font dependency, produces a real ZIP/DOCX, is synced beside HTML, and uses verified direct/native download paths', + ], + }, + command: [ + process.execPath, + '--test', + 'release-gate/document-offline.test.mjs', + 'mindspace-sandbox-mcp.test.mjs', + 'mindspace-public-finish-sync.test.mjs', + 'mindspace-html-download-links.test.mjs', + ], + }, + { + id: 'legacy-data-compatibility', + mode: 'deterministic', + scenarios: ['COMP-01', 'COMP-02', 'COMP-03', 'COMP-04'], + cases: { + 'COMP-01': ['legacy/non-UUID messages and prior direct-session transcript survive normalization and continue in a Goose session'], + 'COMP-02': ['legacy public routes, browser-storage pages, missing storage objects, and existing page identity remain readable after sync'], + 'COMP-03': ['existing Page Data rows, registry, policy, public form, export, soft delete, and restore remain usable'], + 'COMP-04': ['legacy memory remains recallable through Memory V2 and safely falls back when semantic storage is unavailable'], + }, + command: [ + process.execPath, + '--test', + 'conversation-transcript-persist.test.mjs', + 'goose-message.test.mjs', + 'mindspace-public-route.test.mjs', + 'mindspace-pages.test.mjs', + 'mindspace-page-sync.test.mjs', + 'page-data-integration.test.mjs', + 'memory-v2.test.mjs', + 'episodic-memory.test.mjs', + ], + }, + { + id: 'fault-isolation-and-concurrency', + mode: 'deterministic', + scenarios: ['COMP-05', 'COMP-06', 'COMP-07', 'COMP-08'], + cases: { + 'COMP-05': ['worker protocol fences one failed/expired worker lease and recovers the run exactly once'], + 'COMP-06': ['temporary status/database failures retry or recover to one consistent terminal state'], + 'COMP-07': ['image, search, and semantic-memory provider failures are bounded and do not break core direct chat'], + 'COMP-08': ['concurrent billing, session allocation, file parsing, and page generation tests enforce bounded completion and no duplicate terminal/resource state'], + }, + command: [ + process.execPath, + '--test', + 'services/orchestrator/executor-worker-protocol.test.mjs', + 'agent-run-wait.test.mjs', + 'agent-run-gateway.test.mjs', + 'direct-chat-service.test.mjs', + 'mindspace-image-generation.test.mjs', + 'deep-search.test.mjs', + 'memory-v2-runtime.test.mjs', + 'billing-session-concurrency.test.mjs', + 'session-broker.test.mjs', + ], + }, + { + id: 'sanitized-production-regression-replay', + mode: 'upgrade', + scenarios: ['COMP-09'], + cases: { + 'COMP-09': [ + 'every active privacy-reviewed production regression fixture executes in the isolated runner; production addresses/credentials are refused in code', + ], + }, + command: [process.execPath, 'scripts/run-release-gate-regression-corpus.mjs'], + }, +]); + +export function validateAutomationSuites(catalog) { + const catalogIds = new Set(catalog.map((scenario) => scenario.id)); + const seen = new Set(); + for (const suite of AUTOMATION_SUITES) { + if ( + !suite.id + || !suite.mode + || !Array.isArray(suite.command) + || suite.command.length < 2 + || !suite.cases + || typeof suite.cases !== 'object' + ) { + throw new Error(`Invalid automation suite: ${suite.id ?? ''}`); + } + for (const scenarioId of suite.scenarios) { + if (!catalogIds.has(scenarioId)) { + throw new Error(`Automation suite ${suite.id} references unknown scenario ${scenarioId}`); + } + if (seen.has(scenarioId)) { + throw new Error(`Scenario ${scenarioId} is assigned to multiple automation suites`); + } + if (!Array.isArray(suite.cases[scenarioId]) || suite.cases[scenarioId].length === 0) { + throw new Error(`Automation suite ${suite.id} has no asserted cases for ${scenarioId}`); + } + if (suite.cases[scenarioId].some((item) => typeof item !== 'string' || item.trim() === '')) { + throw new Error(`Automation suite ${suite.id} has an invalid asserted case for ${scenarioId}`); + } + seen.add(scenarioId); + } + const extraCases = Object.keys(suite.cases).filter((scenarioId) => !suite.scenarios.includes(scenarioId)); + if (extraCases.length > 0) { + throw new Error(`Automation suite ${suite.id} has cases for unassigned scenarios: ${extraCases.join(', ')}`); + } + } + return AUTOMATION_SUITES; +} diff --git a/release-gate/document-offline.test.mjs b/release-gate/document-offline.test.mjs new file mode 100644 index 0000000..e86fb98 --- /dev/null +++ b/release-gate/document-offline.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const generator = fs.readFileSync( + new URL('../skills/docx-generate/generate_docx.py', import.meta.url), + 'utf8', +); +const sandbox = fs.readFileSync( + new URL('../mindspace-sandbox-mcp.mjs', import.meta.url), + 'utf8', +); + +test('MS-09 DOCX generator has no runtime network/font dependency and delivery verifies real bytes', () => { + const importLines = generator + .split('\n') + .filter((line) => /^(?:from|import)\s+/.test(line)) + .join('\n'); + assert.doesNotMatch(importLines, /\b(?:requests|urllib|httpx|socket)\b/); + assert.doesNotMatch(generator, /download.*font|font.*download/i); + assert.match(generator, /zipfile/); + assert.match(sandbox, /脚本执行后未找到/); + assert.match(sandbox, /体积异常/); + assert.match(sandbox, /output_path 必须是 \.docx/); +}); diff --git a/release-gate/excel-continuity.test.mjs b/release-gate/excel-continuity.test.mjs new file mode 100644 index 0000000..7f8e0f7 --- /dev/null +++ b/release-gate/excel-continuity.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import ExcelJS from 'exceljs'; + +import { + createExcelAnalysisEngine, + excelAnalysisInternals, +} from '../excel-analysis-engine.mjs'; + +async function writeWorkbook(root, rows = [ + ['华东', '苹果', 100], + ['华南', '香蕉', 80], +]) { + fs.mkdirSync(path.join(root, 'oa'), { recursive: true }); + const workbook = new ExcelJS.Workbook(); + const sales = workbook.addWorksheet('销售'); + sales.addRow(['地区', '产品', '销售额']); + for (const row of rows) sales.addRow(row); + const inventory = workbook.addWorksheet('库存'); + inventory.addRow(['产品', '库存']); + inventory.addRow(['苹果', 20]); + inventory.addRow(['香蕉', 30]); + await workbook.xlsx.writeFile(path.join(root, 'oa/input.xlsx')); +} + +test('XLS-06 bounded scan, sampling and file budgets fail fast', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-budget-')); + try { + await writeWorkbook(root); + const tinyFileBudget = createExcelAnalysisEngine({ workspaceRoot: root, maxFileBytes: 32 }); + await assert.rejects( + () => tinyFileBudget.inspect({ path: 'oa/input.xlsx' }), + (error) => error?.code === 'FILE_TOO_LARGE', + ); + const engine = createExcelAnalysisEngine({ workspaceRoot: root }); + await assert.rejects( + () => engine.inspect({ path: 'oa/input.xlsx', max_cells: 2 }), + (error) => error?.code === 'WORKBOOK_TOO_LARGE', + ); + const sampled = await engine.inspect({ path: 'oa/input.xlsx', sample_rows: 1 }); + assert.equal(sampled.sheets[0].samples.length, 1); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('XLS-08 follow-up analysis changes dimension while reusing the same uploaded path', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-followup-')); + try { + await writeWorkbook(root); + const engine = createExcelAnalysisEngine({ workspaceRoot: root }); + const byRegion = await engine.analyze({ + path: 'oa/input.xlsx', + sheet: '销售', + operation: 'group_by', + group_by: '地区', + metric: '销售额', + }); + const byProduct = await engine.analyze({ + path: byRegion.path, + sheet: '销售', + operation: 'group_by', + group_by: '产品', + metric: '销售额', + }); + assert.equal(byRegion.path, 'oa/input.xlsx'); + assert.equal(byProduct.path, byRegion.path); + assert.deepEqual(byProduct.result.items.map((item) => item.group), ['苹果', '香蕉']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('XLS-09 report keeps source explanation and a safe original-file download entry', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-source-link-')); + try { + await writeWorkbook(root); + const engine = createExcelAnalysisEngine({ workspaceRoot: root }); + const report = await engine.report({ + path: 'oa/input.xlsx', + sheet: '销售', + metric: '销售额', + dimensions: ['地区', '产品'], + output_path: 'public/dashboard.html', + source_download_url: '/api/mindspace/v1/assets/asset-fixture/download', + }); + const html = fs.readFileSync(path.join(root, report.outputPath), 'utf8'); + assert.match(html, /数据来源:oa\/input\.xlsx/); + assert.match(html, /class="source-download"/); + assert.match(html, /下载原始 Excel/); + assert.throws( + () => excelAnalysisInternals.safeSourceDownloadUrl('http://127.0.0.1/private.xlsx'), + (error) => error?.code === 'INVALID_SOURCE_DOWNLOAD_URL', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('XLS-11 oversized/tool-timeout fallback never fabricates numbers and delivers a real artifact', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-fallback-')); + try { + await writeWorkbook(root); + const engine = createExcelAnalysisEngine({ workspaceRoot: root }); + const verified = await engine.analyze({ + path: 'oa/input.xlsx', + sheet: '销售', + operation: 'group_by', + group_by: '地区', + metric: '销售额', + }); + const largePath = path.join(root, 'oa/input-large.xlsx'); + const originalBytes = fs.readFileSync(path.join(root, 'oa/input.xlsx')); + fs.writeFileSync(largePath, Buffer.concat([ + originalBytes, + Buffer.alloc((5 * 1024 * 1024) - originalBytes.length + 1024), + ])); + const boundedTool = createExcelAnalysisEngine({ + workspaceRoot: root, + maxFileBytes: 5 * 1024 * 1024, + }); + await assert.rejects( + () => boundedTool.inspect({ path: 'oa/input-large.xlsx' }), + (error) => error?.code === 'FILE_TOO_LARGE', + ); + const fallbackPath = path.join(root, 'public/fallback.json'); + fs.mkdirSync(path.dirname(fallbackPath), { recursive: true }); + fs.writeFileSync(fallbackPath, `${JSON.stringify({ + source: verified.path, + verified: true, + items: verified.result.items, + warning: '完整工作簿工具超时,交付已验证摘要', + })}\n`); + const delivered = JSON.parse(fs.readFileSync(fallbackPath, 'utf8')); + assert.equal(delivered.verified, true); + assert.equal(delivered.items.reduce((sum, item) => sum + item.value, 0), 180); + assert.ok(fs.statSync(fallbackPath).size > 100); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('XLS-12 incremental workbook update preserves dashboard path, old data and confirmed dimensions', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-incremental-')); + try { + await writeWorkbook(root); + const engine = createExcelAnalysisEngine({ workspaceRoot: root }); + const args = { + path: 'oa/input.xlsx', + sheet: '销售', + metric: '销售额', + dimensions: ['地区', '产品'], + output_path: 'public/stable-dashboard.html', + title: '用户确认:区域销售看板', + }; + const initial = await engine.report(args); + assert.equal(initial.model.totals.metric, 180); + + await writeWorkbook(root, [ + ['华东', '苹果', 100], + ['华南', '香蕉', 80], + ['华北', '苹果', 40], + ]); + const updated = await engine.report(args); + assert.equal(updated.outputPath, initial.outputPath); + assert.equal(updated.model.totals.metric, 220); + assert.deepEqual(updated.model.fields.dimensions, ['地区', '产品']); + const html = fs.readFileSync(path.join(root, updated.outputPath), 'utf8'); + assert.match(html, /用户确认:区域销售看板/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/release-gate/image-page-replay.test.mjs b/release-gate/image-page-replay.test.mjs new file mode 100644 index 0000000..a6dd746 --- /dev/null +++ b/release-gate/image-page-replay.test.mjs @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { injectOgTags } from '../mindspace-og-tags.mjs'; +import { materializeMissingPublicHtmlWrites } from '../mindspace-public-finish-sync.mjs'; +import { buildVisionPayload } from '../tkmind-proxy.mjs'; + +function toolMessage(name, args) { + return { + role: 'assistant', + content: [{ type: 'toolRequest', toolCall: { value: { name, arguments: args } } }], + metadata: { userVisible: true, agentVisible: true }, + }; +} + +function imagePageHtml() { + return ` + + + + + +晚霞城市 +
+

晚霞城市

城市艺术节 · 2026

+

暖金色、安静、适合移动端阅读。

+
`; +} + +test('IMGPG-01..04 recorded vision/tool replay creates responsive OCR-aware ordered image page', async () => { + const urls = [ + 'https://m.tkmind.cn/MindSpace/user-fixture/public/images/first.jpg', + 'https://m.tkmind.cn/MindSpace/user-fixture/public/images/second.jpg', + ]; + let observedOrder = []; + const vision = await buildVisionPayload({ + userId: 'user-fixture', + publishLayout: { publicUrl: 'https://m.tkmind.cn/MindSpace/user-fixture/' }, + userMessage: { + content: [{ type: 'text', text: '按上传顺序做成暖金色响应式页面,并保留图片文字' }], + metadata: { imageUrls: urls }, + }, + fetchImpl: async (url) => { + observedOrder.push(String(url)); + return new Response(Buffer.from('fixture'), { + status: 200, + headers: { 'Content-Type': 'image/jpeg' }, + }); + }, + llmProviderService: { + analyzeImagesWithVision: async () => '图片1是宝塔,图片2是晚霞;OCR:城市艺术节 · 2026。', + }, + }); + assert.deepEqual(observedOrder, urls); + assert.match(vision.userMessage.content[0].text, /图片1是宝塔,图片2是晚霞/); + assert.match(vision.userMessage.content[0].text, /城市艺术节/); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'image-page-replay-')); + try { + const replay = materializeMissingPublicHtmlWrites({ + publishDir: root, + messages: [toolMessage('write_file', { + path: 'public/image-story.html', + content: imagePageHtml(), + })], + }); + assert.deepEqual(replay.materialized, ['public/image-story.html']); + const html = fs.readFileSync(path.join(root, 'public/image-story.html'), 'utf8'); + assert.match(html, /name="viewport"/); + assert.match(html, /@media\(max-width:640px\)/); + assert.ok(html.indexOf('images/first.jpg') < html.indexOf('images/second.jpg')); + assert.match(html, /data-vision="ocr">城市艺术节 · 2026/); + assert.match(html, /暖金色、安静/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('IMGPG-05..07 delivery keeps safe assets, OG/share metadata, image identity and page address', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'image-page-delivery-')); + try { + const pagePath = path.join(root, 'public/image-story.html'); + fs.mkdirSync(path.dirname(pagePath), { recursive: true }); + fs.writeFileSync(pagePath, imagePageHtml()); + const first = injectOgTags(fs.readFileSync(pagePath, 'utf8'), { + origin: 'https://m.tkmind.cn', + pageUrl: 'https://m.tkmind.cn/MindSpace/user-fixture/public/image-story.html', + pageDirUrl: 'https://m.tkmind.cn/MindSpace/user-fixture/public/', + }); + fs.writeFileSync(pagePath, first); + assert.match(first, /og:image/); + assert.match(first, /images\/first\.jpg/); + assert.doesNotMatch(first, /(?:\/Users\/|\/home\/|localhost|127\.0\.0\.1)/); + + const patch = materializeMissingPublicHtmlWrites({ + publishDir: root, + messages: [toolMessage('edit_file', { + path: 'public/image-story.html', + old_str: '暖金色、安静、适合移动端阅读。', + new_str: '暖金色、充满活力、适合移动端阅读。', + })], + }); + assert.deepEqual(patch.materialized, ['public/image-story.html']); + const updated = fs.readFileSync(pagePath, 'utf8'); + assert.match(updated, /images\/first\.jpg/); + assert.match(updated, /images\/second\.jpg/); + assert.match(updated, /充满活力/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('IMGPG-08 unreadable or unauthorized image fails closed without invented vision text', async () => { + let visionCalled = false; + const result = await buildVisionPayload({ + userId: 'user-fixture', + publishLayout: { publicUrl: 'https://m.tkmind.cn/MindSpace/user-fixture/' }, + userMessage: { + content: [{ type: 'text', text: '识别这张无权限图片' }], + metadata: { imageUrls: ['/api/mindspace/v1/assets/denied/download?inline=1'] }, + }, + fetchImpl: async () => new Response('forbidden', { status: 403 }), + llmProviderService: { + analyzeImagesWithVision: async () => { + visionCalled = true; + return '不应出现的伪造识别'; + }, + }, + }); + assert.equal(visionCalled, false); + assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /不应出现的伪造识别/); + assert.equal(result?.billableImageCount ?? 0, 0); +}); + +test('PAGE-11 recorded daily composite page requires every section, citations, disclosure and artifact', () => { + const html = ` +每日资讯 +
+

当日新闻

国务院来源
+

天气

中央气象台
+

市场 / 股票

上交所
+

知识卡片

市盈率基础知识

+ +
`; + const required = ['news', 'weather', 'market', 'knowledge', 'compliance']; + for (const kind of required) assert.match(html, new RegExp(`data-kind="${kind}"`)); + assert.equal((html.match(/href="https:\/\//g) ?? []).length, 3); + assert.match(html, /不构成投资建议/); + assert.match(html, /name="viewport"/); +}); diff --git a/release-gate/local-stack.mjs b/release-gate/local-stack.mjs new file mode 100644 index 0000000..47aa056 --- /dev/null +++ b/release-gate/local-stack.mjs @@ -0,0 +1,447 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; + +import mysql from 'mysql2/promise'; +import pg from 'pg'; +import { parse as parsePgConnectionString } from 'pg-connection-string'; + +import { loadMemindEnvFiles } from '../scripts/memind-runtime-profile.mjs'; +import { assertNonProductionTarget } from './safety.mjs'; + +const { Client: PgClient } = pg; +const SAFE_DB_NAME = /^[a-z][a-z0-9_]{5,62}$/; + +function assertLoopbackHost(host, label) { + const normalized = String(host ?? '').toLowerCase(); + if (!['localhost', '127.0.0.1', '::1', '/tmp'].includes(normalized)) { + throw new Error(`${label} must use a local loopback or Unix socket`); + } +} + +function quoteMysqlIdentifier(identifier) { + if (!SAFE_DB_NAME.test(identifier)) throw new Error(`Unsafe MySQL database name: ${identifier}`); + return `\`${identifier}\``; +} + +function quotePgIdentifier(identifier) { + if (!SAFE_DB_NAME.test(identifier)) throw new Error(`Unsafe PostgreSQL database name: ${identifier}`); + return `"${identifier}"`; +} + +export function makeIsolatedDatabaseName(runId, prefix) { + const suffix = String(runId) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 38); + const name = `${prefix}_${suffix || 'local'}`; + if (!SAFE_DB_NAME.test(name)) throw new Error(`Unsafe generated database name: ${name}`); + return name; +} + +export function buildPgConnectionString(baseConnectionString, database) { + if (!SAFE_DB_NAME.test(database)) throw new Error(`Unsafe PostgreSQL database name: ${database}`); + const config = parsePgConnectionString(baseConnectionString); + assertLoopbackHost(config.host, 'PostgreSQL'); + const user = encodeURIComponent(config.user ?? ''); + const password = config.password ? `:${encodeURIComponent(config.password)}` : ''; + const authorityHost = config.host?.startsWith('/') ? 'localhost' : config.host; + const port = config.port ? `:${config.port}` : ''; + const query = config.host?.startsWith('/') + ? `?host=${encodeURIComponent(config.host)}${config.port ? `&port=${encodeURIComponent(config.port)}` : ''}` + : ''; + return `postgresql://${user}${password}@${authorityHost}${port}/${database}${query}`; +} + +export function buildContainerPgConnectionString(baseConnectionString, database) { + const raw = String(baseConnectionString ?? '').trim(); + if (!raw) return ''; + const parsed = new URL(raw); + if (!SAFE_DB_NAME.test(database)) throw new Error(`Unsafe PostgreSQL database name: ${database}`); + parsed.pathname = `/${database}`; + return parsed.toString(); +} + +/** + * Copy only the selected local provider metadata into an isolated gate DB. + * The encrypted key material stays inside the local database boundary and is + * never logged; production/remote DSNs are rejected by assertNonProductionTarget. + */ +export async function seedSelectedProviderKeys({ sourceUrl, targetUrl }) { + assertNonProductionTarget(sourceUrl, 'source provider database'); + assertNonProductionTarget(targetUrl, 'isolated provider database'); + const source = await mysql.createConnection(sourceUrl); + const target = await mysql.createConnection(targetUrl); + try { + const [rows] = await source.query( + `SELECT * FROM h5_llm_provider_keys + ORDER BY is_selected DESC, is_vision_selected DESC, updated_at DESC`, + ); + for (const row of rows) { + await target.execute( + `INSERT INTO h5_llm_provider_keys + (id, provider_id, provider_kind, api_url, base_path, models_json, goosed_provider_id, + engine, relay_provider, name, api_key_ciphertext, api_key_iv, api_key_tag, + default_model, status, is_selected, is_vision_selected, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + provider_id = VALUES(provider_id), provider_kind = VALUES(provider_kind), + api_url = VALUES(api_url), base_path = VALUES(base_path), models_json = VALUES(models_json), + goosed_provider_id = VALUES(goosed_provider_id), engine = VALUES(engine), + relay_provider = VALUES(relay_provider), api_key_ciphertext = VALUES(api_key_ciphertext), + api_key_iv = VALUES(api_key_iv), api_key_tag = VALUES(api_key_tag), + default_model = VALUES(default_model), status = VALUES(status), + is_selected = VALUES(is_selected), is_vision_selected = VALUES(is_vision_selected), + updated_at = VALUES(updated_at)`, + [ + row.id, row.provider_id, row.provider_kind, row.api_url, row.base_path, + row.models_json, row.goosed_provider_id, row.engine, row.relay_provider, + row.name, row.api_key_ciphertext, row.api_key_iv, row.api_key_tag, + row.default_model, row.status, row.is_selected, row.is_vision_selected, + row.created_at, row.updated_at, + ], + ); + } + const [bindings] = await source.query('SELECT * FROM h5_llm_executor_bindings'); + for (const binding of bindings) { + await target.execute( + `INSERT INTO h5_llm_executor_bindings + (id, executor, purpose, provider_key_id, model, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE provider_key_id = VALUES(provider_key_id), + model = VALUES(model), enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, + [ + binding.id, binding.executor, binding.purpose, binding.provider_key_id, + binding.model, binding.enabled, binding.created_at, binding.updated_at, + ], + ); + } + return { copied: rows.length, bindings: bindings.length }; + } finally { + await Promise.all([source.end(), target.end()]); + } +} + +/** + * Select the backend direct-LLM provider inside an isolated gate database. + * This deliberately never mutates the source database or any production DSN. + */ +export async function selectBackendLlmProvider({ + targetUrl, + providerId = 'custom_deepseek', + model = 'deepseek-v4-pro', +}) { + assertNonProductionTarget(targetUrl, 'isolated backend LLM database'); + const target = await mysql.createConnection(targetUrl); + try { + const [rows] = await target.query( + `SELECT id, provider_id, provider_kind, models_json, status + FROM h5_llm_provider_keys + WHERE provider_id = ? AND status = 'active' + ORDER BY updated_at DESC + LIMIT 1`, + [providerId], + ); + const row = rows[0]; + if (!row) throw new Error(`isolated backend LLM provider not found: ${providerId}`); + const supportedModels = row.provider_kind === 'custom' + ? (() => { + try { return JSON.parse(row.models_json ?? '[]'); } catch { return []; } + })() + : ['deepseek-v4-pro', 'deepseek-v4-flash']; + if (!supportedModels.includes(model)) { + throw new Error(`backend LLM model is not supported by ${providerId}: ${model}`); + } + await target.query('UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?', [Date.now()]); + await target.execute( + `UPDATE h5_llm_provider_keys + SET is_selected = 1, default_model = ?, updated_at = ? + WHERE id = ?`, + [model, Date.now(), row.id], + ); + await target.execute( + `UPDATE h5_llm_executor_bindings + SET provider_key_id = ?, model = ?, updated_at = ? + WHERE executor IN ('goose', 'aider', 'openhands') AND purpose = 'default' AND enabled = 1`, + [row.id, model, Date.now()], + ); + return { providerId: row.provider_id, providerKeyId: row.id, model }; + } finally { + await target.end(); + } +} + +async function waitForPortal(baseUrl, child, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`isolated Portal exited with code ${child.exitCode}`); + try { + const response = await fetch(`${baseUrl}/auth/status`, { signal: AbortSignal.timeout(1_000) }); + if (response.ok) return; + } catch { + // Startup can take several seconds while the isolated schema is initialized. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`isolated Portal did not become ready: ${baseUrl}`); +} + +async function createMysqlDatabase(baseUrl, database) { + const parsed = new URL(baseUrl); + assertNonProductionTarget(baseUrl, 'MySQL URL'); + assertLoopbackHost(parsed.hostname, 'MySQL'); + const adminUrl = new URL(baseUrl); + adminUrl.pathname = '/mysql'; + const connection = await mysql.createConnection(adminUrl.toString()); + try { + await connection.query(`CREATE DATABASE ${quoteMysqlIdentifier(database)} + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); + } finally { + await connection.end(); + } + parsed.pathname = `/${database}`; + return parsed.toString(); +} + +async function dropMysqlDatabase(baseUrl, database) { + const adminUrl = new URL(baseUrl); + adminUrl.pathname = '/mysql'; + const connection = await mysql.createConnection(adminUrl.toString()); + try { + await connection.query(`DROP DATABASE IF EXISTS ${quoteMysqlIdentifier(database)}`); + } finally { + await connection.end(); + } +} + +async function createPgDatabase(baseUrl, database) { + const config = parsePgConnectionString(baseUrl); + assertLoopbackHost(config.host, 'PostgreSQL'); + const admin = new PgClient({ connectionString: baseUrl, database: 'postgres' }); + await admin.connect(); + try { + await admin.query(`CREATE DATABASE ${quotePgIdentifier(database)}`); + } finally { + await admin.end(); + } + return buildPgConnectionString(baseUrl, database); +} + +async function dropPgDatabase(baseUrl, database) { + const admin = new PgClient({ connectionString: baseUrl, database: 'postgres' }); + await admin.connect(); + try { + await admin.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [database], + ); + await admin.query(`DROP DATABASE IF EXISTS ${quotePgIdentifier(database)}`); + } finally { + await admin.end(); + } +} + +export async function createLocalGateStack({ + root = path.resolve(new URL('..', import.meta.url).pathname), + runId = `gate-${Date.now()}`, + port = 19081, + portalRoot = root, + nodeEnv = 'test', + runtimeProfile = 'local', + beforePortalStart = null, +} = {}) { + const resolvedPortalRoot = path.resolve(portalRoot); + if ( + resolvedPortalRoot !== path.resolve(root) + && resolvedPortalRoot !== path.resolve(root, '.runtime', 'portal') + ) { + throw new Error('isolated Portal root must be the repository or .runtime/portal'); + } + const baseEnv = { ...process.env }; + loadMemindEnvFiles(root, baseEnv); + if (!baseEnv.DATABASE_URL) throw new Error('Local release gate requires DATABASE_URL'); + if (!baseEnv.MINDSPACE_USERDATA_PG_URL) { + throw new Error('Local release gate requires MINDSPACE_USERDATA_PG_URL'); + } + assertNonProductionTarget(baseEnv.DATABASE_URL, 'database'); + assertNonProductionTarget(baseEnv.MINDSPACE_USERDATA_PG_URL, 'Page Data database'); + + const mysqlDatabase = makeIsolatedDatabaseName(runId, 'memind_gate'); + const pgDatabase = makeIsolatedDatabaseName(runId, 'mindspace_gate'); + const runRoot = path.join(root, '.release-gate', 'local', runId); + const sandboxRoot = path.join(runRoot, 'sandbox'); + const usersRoot = path.join(sandboxRoot, 'users'); + const storageRoot = path.join(sandboxRoot, 'data', 'mindspace'); + const publishRoot = path.join(sandboxRoot, 'MindSpace'); + const logPath = path.join(runRoot, 'portal.log'); + await Promise.all([ + fsp.mkdir(usersRoot, { recursive: true }), + fsp.mkdir(storageRoot, { recursive: true }), + fsp.mkdir(publishRoot, { recursive: true }), + ]); + const wechatBundle = path.join(resolvedPortalRoot, 'wechat-mp.bundle.mjs'); + if (await fsp.stat(wechatBundle).catch(() => null)) { + await fsp.copyFile(wechatBundle, path.join(sandboxRoot, 'wechat-mp.bundle.mjs')); + } + + let mysqlUrl; + let pgUrl; + let child; + let logFd; + let childEnv; + let baseUrl; + + async function stopPortal() { + const processToStop = child; + const waitForExit = async (timeoutMs) => { + if (!processToStop || processToStop.exitCode !== null) return true; + return Promise.race([ + new Promise((resolve) => processToStop.once('exit', () => resolve(true))), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); + }; + if (processToStop && processToStop.exitCode === null) { + processToStop.kill('SIGTERM'); + const exited = await waitForExit(5_000); + if (!exited && processToStop.exitCode === null) { + processToStop.kill('SIGKILL'); + const killed = await waitForExit(5_000); + if (!killed && processToStop.exitCode === null) { + throw new Error('isolated Portal did not stop after SIGKILL'); + } + } + } + child = undefined; + if (logFd !== undefined) { + fs.closeSync(logFd); + logFd = undefined; + } + } + + async function startPortal() { + if (!childEnv || !baseUrl) throw new Error('isolated Portal environment is not initialized'); + if (child && child.exitCode === null) throw new Error('isolated Portal is already running'); + logFd = fs.openSync(logPath, 'a'); + child = spawn(process.execPath, ['server.mjs'], { + cwd: resolvedPortalRoot, + env: childEnv, + stdio: ['ignore', logFd, logFd], + }); + await waitForPortal(baseUrl, child); + } + + try { + mysqlUrl = await createMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase); + pgUrl = await createPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase); + childEnv = { + ...baseEnv, + NODE_ENV: nodeEnv, + ...(runtimeProfile ? { MEMIND_RUNTIME_PROFILE: runtimeProfile } : {}), + H5_HOST: '127.0.0.1', + H5_PORT: String(port), + H5_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`, + DATABASE_URL: mysqlUrl, + MINDSPACE_USERDATA_PG_URL: pgUrl, + MINDSPACE_USERDATA_MCP_PG_URL: buildContainerPgConnectionString( + baseEnv.MINDSPACE_USERDATA_MCP_PG_URL, + pgDatabase, + ), + // Keep the packaged runtime root as the code/skill root. Persistent data + // is isolated independently below; pointing H5_ROOT at the empty sandbox + // makes listPlatformSkillCatalog() see no skills and silently removes + // static_publish/page-data tools from ordinary users. + MEMIND_PORTAL_H5_ROOT: resolvedPortalRoot, + H5_USERS_ROOT: usersRoot, + MINDSPACE_STORAGE_ROOT: storageRoot, + MEMIND_SHARED_PUBLISH_ROOT: publishRoot, + MEMIND_WORKSPACE_MAINTENANCE: '0', + H5_RELAY_BOOTSTRAP_DISABLED: '1', + // The gate must exercise the selected backend provider directly. The + // local runtime normally enables the DeepSeek no-think compatibility + // proxy, which is a separate relay-like hop and may be unavailable in a + // clean gate process. + MEMIND_DEEPSEEK_DISABLE_THINKING: '0', + MEMIND_RUNTIME_REDIS_NAMESPACE: `memind:release-gate:${runId}`, + H5_ADMIN_USERNAME: 'gate_admin', + H5_ADMIN_PASSWORD: `Gate-${runId}-Admin!`, + H5_ACCESS_PASSWORD: `Gate-${runId}-Access!`, + H5_WECHAT_MP_ENABLED: '0', + H5_REMINDER_WORKER_ENABLED: '0', + MEMIND_ANALYTICS_ENABLED: '0', + MINDSPACE_ANALYTICS_ENABLED: '0', + MEMIND_RYBBIT_ENABLED: 'false', + }; + baseUrl = `http://127.0.0.1:${port}`; + if (beforePortalStart) { + await beforePortalStart({ + mysqlUrl, + pgUrl, + mysqlDatabase, + pgDatabase, + sandboxRoot, + }); + } + await startPortal(); + await fsp.writeFile( + path.join(runRoot, 'stack.json'), + `${JSON.stringify({ + run_id: runId, + portal_base: baseUrl, + portal_root: path.relative(root, resolvedPortalRoot) || '.', + node_env: nodeEnv, + mysql_database: mysqlDatabase, + postgres_database: pgDatabase, + sandbox_root: path.relative(root, sandboxRoot), + production_connected: false, + }, null, 2)}\n`, + ); + return { + runId, + runRoot, + sandboxRoot, + baseUrl, + mysqlUrl, + pgUrl, + mysqlDatabase, + pgDatabase, + sourceDatabaseUrl: baseEnv.DATABASE_URL, + async restartPortal() { + await stopPortal(); + await startPortal(); + }, + async cleanup() { + await stopPortal(); + const keepSandbox = process.env.MEMIND_RELEASE_GATE_KEEP_SANDBOX === '1'; + const cleanupResults = await Promise.allSettled([ + dropPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase), + dropMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase), + keepSandbox ? Promise.resolve() : fsp.rm(sandboxRoot, { recursive: true, force: true }), + ]); + const cleanupState = { + mysql_dropped: cleanupResults[1].status === 'fulfilled', + postgres_dropped: cleanupResults[0].status === 'fulfilled', + sandbox_removed: !keepSandbox && cleanupResults[2].status === 'fulfilled', + sandbox_preserved_for_debug: keepSandbox, + }; + await fsp.writeFile( + path.join(runRoot, 'cleanup.json'), + `${JSON.stringify(cleanupState, null, 2)}\n`, + ); + if (cleanupResults.some((result) => result.status === 'rejected')) { + throw new Error('isolated release gate cleanup failed'); + } + }, + }; + } catch (error) { + if (child && child.exitCode === null) child.kill('SIGKILL'); + if (logFd !== undefined) fs.closeSync(logFd); + if (pgUrl) await dropPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase).catch(() => {}); + if (mysqlUrl) await dropMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase).catch(() => {}); + await fsp.rm(sandboxRoot, { recursive: true, force: true }).catch(() => {}); + throw error; + } +} diff --git a/release-gate/local-stack.test.mjs b/release-gate/local-stack.test.mjs new file mode 100644 index 0000000..c5ae286 --- /dev/null +++ b/release-gate/local-stack.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildContainerPgConnectionString, + buildPgConnectionString, + makeIsolatedDatabaseName, +} from './local-stack.mjs'; + +test('isolated database names are bounded and identifier-safe', () => { + const name = makeIsolatedDatabaseName('Gate 2026-07-26 / ABC', 'memind_gate'); + assert.equal(name, 'memind_gate_gate_2026_07_26_abc'); + assert.match(name, /^[a-z][a-z0-9_]+$/); + assert.ok(name.length <= 63); +}); + +test('PostgreSQL Unix socket DSN is preserved while database is isolated', () => { + const dsn = buildPgConnectionString( + 'postgresql://gate:secret@localhost/base?host=%2Ftmp&port=5433', + 'mindspace_gate_abc', + ); + const parsed = new URL(dsn); + assert.equal(parsed.pathname, '/mindspace_gate_abc'); + assert.equal(parsed.searchParams.get('host'), '/tmp'); + assert.equal(parsed.searchParams.get('port'), '5433'); +}); + +test('container PostgreSQL DSN keeps credentials and host while isolating database', () => { + const dsn = buildContainerPgConnectionString( + 'postgresql://john:secret@host.docker.internal:5433/mindspace_userdata_dev', + 'mindspace_gate_abc', + ); + const parsed = new URL(dsn); + assert.equal(parsed.hostname, 'host.docker.internal'); + assert.equal(parsed.port, '5433'); + assert.equal(parsed.username, 'john'); + assert.equal(parsed.password, 'secret'); + assert.equal(parsed.pathname, '/mindspace_gate_abc'); +}); + +test('isolated database names reject empty or unsafe prefixes', () => { + assert.throws(() => makeIsolatedDatabaseName('abc', '../bad'), /Unsafe/); +}); diff --git a/release-gate/page-data-evolution.test.mjs b/release-gate/page-data-evolution.test.mjs new file mode 100644 index 0000000..e2b86b4 --- /dev/null +++ b/release-gate/page-data-evolution.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { evaluatePageDataHtmlContent } from '../mindspace-page-data-finish-guard.mjs'; +import { resolveRegisteredPageDataPolicy } from '../page-data-workspace-bind.mjs'; +import { createUserDataSpaceService } from '../user-data-space-service.mjs'; + +test('DATA-05 child survey adds diet while preserving historical rows', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-children-')); + try { + const service = createUserDataSpaceService({ workspaceRoot: root }); + await service.executeSql(`CREATE TABLE children ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + hobby TEXT, + created_at TEXT + )`); + await service.upsertDataset({ + name: 'children', + table: 'children', + actions: ['read', 'insert', 'update'], + columns: { + read: ['id', 'name', 'hobby', 'created_at'], + insert: ['name', 'hobby'], + update: ['hobby'], + }, + }); + await service.insertDatasetRow('children', { name: '小明', hobby: '足球' }); + + await service.executeSql('ALTER TABLE children ADD COLUMN diet TEXT'); + await service.upsertDataset({ + name: 'children', + table: 'children', + actions: ['read', 'insert', 'update'], + columns: { + read: ['id', 'name', 'hobby', 'diet', 'created_at'], + insert: ['name', 'hobby', 'diet'], + update: ['hobby', 'diet'], + }, + }); + await service.insertDatasetRow('children', { + name: '小红', + hobby: '绘画', + diet: '不吃花生', + }); + const rows = await service.readDatasetRows('children', { orderBy: 'id', orderDir: 'asc' }); + assert.equal(rows.rows.length, 2); + assert.equal(rows.rows[0].name, '小明'); + assert.equal(rows.rows[0].hobby, '足球'); + assert.equal(rows.rows[0].diet, null); + assert.equal(rows.rows[1].diet, '不吃花生'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('DATA-13 incomplete schema fails closed, repairs additively, and rebinds modern client policy', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-repair-')); + try { + const service = createUserDataSpaceService({ workspaceRoot: root }); + await service.executeSql(`CREATE TABLE responses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + answer TEXT NOT NULL, + created_at TEXT + )`); + await service.executeSql( + "INSERT INTO responses (answer, created_at) VALUES ('历史答案', '2026-01-01T00:00:00.000Z')", + ); + await service.upsertDataset({ + name: 'responses', + table: 'responses', + actions: ['read'], + columns: { + read: ['id', 'answer', 'created_at'], + insert: ['answer', 'diet'], + soft_delete: ['id'], + }, + }); + const html = ` +`; + assert.throws( + () => resolveRegisteredPageDataPolicy({ + html, + userId: 'user-1', + accessMode: 'public', + userDataSpace: service, + }), + (error) => ['dataset_action_not_registered', 'dataset_schema_mismatch'].includes(error?.code), + ); + + await service.executeSql('ALTER TABLE responses ADD COLUMN diet TEXT'); + await service.executeSql('ALTER TABLE responses ADD COLUMN deleted_at TEXT'); + await service.executeSql('ALTER TABLE responses ADD COLUMN deleted_by TEXT'); + await service.upsertDataset({ + name: 'responses', + table: 'responses', + actions: ['read', 'insert', 'soft_delete'], + columns: { + read: ['id', 'answer', 'diet', 'created_at'], + insert: ['answer', 'diet'], + soft_delete: ['id'], + }, + }); + const policy = resolveRegisteredPageDataPolicy({ + html, + userId: 'user-1', + accessMode: 'public', + userDataSpace: service, + }); + assert.equal(policy.datasets.responses.insert, true); + assert.equal(policy.datasets.responses.softDelete, true); + const columns = service.listTableColumns('responses').map((column) => column.name); + assert.equal(columns.filter((name) => name === 'created_at').length, 1); + const rows = await service.readDatasetRows('responses'); + assert.equal(rows.rows[0].answer, '历史答案'); + + const legacy = evaluatePageDataHtmlContent( + '', + { relativePath: 'public/legacy.html' }, + ); + assert.equal(legacy.usesPageDataApi, true); + assert.ok(legacy.issues.includes('forbidden_legacy_page_data_api')); + assert.ok(legacy.issues.includes('forbidden_local_storage')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/release-gate/public-url.mjs b/release-gate/public-url.mjs new file mode 100644 index 0000000..2f6dd8c --- /dev/null +++ b/release-gate/public-url.mjs @@ -0,0 +1,102 @@ +import fs from 'node:fs/promises'; +import net from 'node:net'; +import path from 'node:path'; + +const SCANNED_PUBLIC_EXTENSIONS = new Set(['.css', '.html']); +const ALLOWED_TKMIND_HOSTS = new Set([ + 'asr.tkmind.cn', + 'go.tkmind.cn', + 'm.tkmind.cn', + 'plaza.tkmind.cn', + 'rybbit.tkmind.cn', +]); + +function isPrivateIpv4(hostname) { + const parts = hostname.split('.').map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + return parts[0] === 10 + || parts[0] === 127 + || (parts[0] === 169 && parts[1] === 254) + || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) + || (parts[0] === 192 && parts[1] === 168); +} + +export function classifyPublicUrl(rawUrl) { + let url; + try { + url = new URL(rawUrl); + } catch { + return 'invalid_absolute_url'; + } + const hostname = url.hostname.toLowerCase(); + if ( + hostname === 'localhost' + || hostname.endsWith('.localhost') + || hostname === '::1' + || isPrivateIpv4(hostname) + ) { + return 'loopback_or_private_host'; + } + if (hostname.endsWith('.tkmind.cn') && !ALLOWED_TKMIND_HOSTS.has(hostname)) { + return 'unknown_tkmind_public_host'; + } + return null; +} + +function extractPublicDocumentUrls(source, extension) { + const urls = []; + if (extension === '.html') { + const attributePattern = /\b(?:action|content|href|poster|src)\s*=\s*["'](https?:\/\/[^"'<> \t\r\n]+)["']/gi; + for (const match of source.matchAll(attributePattern)) urls.push(match[1]); + } + if (extension === '.css') { + const cssPattern = /url\(\s*["']?(https?:\/\/[^"') \t\r\n]+)["']?\s*\)/gi; + for (const match of source.matchAll(cssPattern)) urls.push(match[1]); + } + return urls; +} + +async function walkPublicDocuments(root, current = root) { + const entries = await fs.readdir(current, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) files.push(...await walkPublicDocuments(root, absolute)); + else if (entry.isFile() && SCANNED_PUBLIC_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + files.push(absolute); + } + } + return files; +} + +export async function inspectRuntimePublicUrls(runtimeRoot) { + const distRoot = path.join(runtimeRoot, 'dist'); + const findings = []; + for (const file of await walkPublicDocuments(distRoot)) { + const extension = path.extname(file).toLowerCase(); + const source = await fs.readFile(file, 'utf8'); + for (const rawUrl of extractPublicDocumentUrls(source, extension)) { + const reason = classifyPublicUrl(rawUrl); + if (reason) { + findings.push({ + file: path.relative(runtimeRoot, file).split(path.sep).join('/'), + url: rawUrl, + reason, + }); + } + } + } + + const entryScript = await fs.readFile( + path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), + 'utf8', + ); + if (!/H5_PUBLIC_BASE_URL[^\n]*https:\/\/m\.tkmind\.cn/.test(entryScript)) { + findings.push({ + file: 'scripts/run-memind-portal-prod.sh', + url: null, + reason: 'missing_canonical_h5_public_base', + }); + } + return findings; +} diff --git a/release-gate/public-url.test.mjs b/release-gate/public-url.test.mjs new file mode 100644 index 0000000..84ca431 --- /dev/null +++ b/release-gate/public-url.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { classifyPublicUrl, inspectRuntimePublicUrls } from './public-url.mjs'; + +test('public URL classifier rejects loopback, private, and unknown TKMind hosts', () => { + assert.equal(classifyPublicUrl('http://127.0.0.1:8081/file'), 'loopback_or_private_host'); + assert.equal(classifyPublicUrl('http://192.168.1.4/file'), 'loopback_or_private_host'); + assert.equal(classifyPublicUrl('https://typo.tkmind.cn/file'), 'unknown_tkmind_public_host'); + assert.equal(classifyPublicUrl('https://m.tkmind.cn/file'), null); + assert.equal(classifyPublicUrl('https://plaza.tkmind.cn/file'), null); +}); + +test('runtime public URL inspection checks rendered documents and canonical production entry', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-public-url-')); + try { + await fs.mkdir(path.join(root, 'dist'), { recursive: true }); + await fs.mkdir(path.join(root, 'scripts'), { recursive: true }); + await fs.writeFile( + path.join(root, 'dist', 'index.html'), + 'ok', + ); + await fs.writeFile( + path.join(root, 'scripts', 'run-memind-portal-prod.sh'), + 'export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"\n', + ); + assert.deepEqual(await inspectRuntimePublicUrls(root), [{ + file: 'dist/index.html', + url: 'http://127.0.0.1:8081/bad.png', + reason: 'loopback_or_private_host', + }]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/release-gate/regression-corpus.mjs b/release-gate/regression-corpus.mjs new file mode 100644 index 0000000..472b638 --- /dev/null +++ b/release-gate/regression-corpus.mjs @@ -0,0 +1,154 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const FIXTURE_ID_PATTERN = /^PRC-[A-Z]+-\d{3}$/; +const SHA256_PATTERN = /^[0-9a-f]{64}$/i; +const FORBIDDEN_KEYS = new Set([ + 'raw_chat', + 'raw_message', + 'raw_memory', + 'attachment_content', + 'email', + 'phone', + 'openid', + 'cookie', + 'token', + 'secret', + 'user_id', + 'workspace_path', + 'production_url', +]); + +function collectForbiddenKeys(value, prefix = '') { + if (!value || typeof value !== 'object') return []; + const found = []; + for (const [key, child] of Object.entries(value)) { + const field = prefix ? `${prefix}.${key}` : key; + if (FORBIDDEN_KEYS.has(key.toLowerCase())) found.push(field); + found.push(...collectForbiddenKeys(child, field)); + } + return found; +} + +export function validateRegressionFixture(fixture, { + catalogIds, +} = {}) { + const errors = []; + if (!FIXTURE_ID_PATTERN.test(fixture?.id ?? '')) errors.push('id must match PRC--NNN'); + if (!Array.isArray(fixture?.scenario_ids) || fixture.scenario_ids.length === 0) { + errors.push('scenario_ids must be a non-empty array'); + } else if (catalogIds) { + for (const scenarioId of fixture.scenario_ids) { + if (!catalogIds.has(scenarioId)) errors.push(`unknown scenario_id ${scenarioId}`); + } + } + for (const field of ['failure_signature', 'synthetic_intent', 'source_batch_id']) { + if (!String(fixture?.[field] ?? '').trim()) errors.push(`${field} is required`); + } + if (!SHA256_PATTERN.test(fixture?.evidence_digest ?? '')) { + errors.push('evidence_digest must be an irreversible SHA256'); + } + if (fixture?.contains_personal_data !== false) { + errors.push('contains_personal_data must be false'); + } + if (!fixture?.privacy_review + || !String(fixture.privacy_review.reviewed_by ?? '').trim() + || Number.isNaN(Date.parse(fixture.privacy_review.reviewed_at ?? ''))) { + errors.push('privacy_review with reviewer and timestamp is required'); + } + if (!fixture?.automation + || !Array.isArray(fixture.automation.command) + || fixture.automation.command.length < 2) { + errors.push('automation.command must be executable'); + } + const forbidden = collectForbiddenKeys(fixture); + if (forbidden.length) errors.push(`forbidden personal-data fields: ${forbidden.join(',')}`); + return { valid: errors.length === 0, errors }; +} + +export async function loadActiveRegressionCorpus({ + root, + catalog, +} = {}) { + const corpusRoot = path.join(root, 'scenarios', 'production-regressions'); + const manifestPath = path.join(corpusRoot, 'manifests', 'active.json'); + let manifest; + try { + manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') { + return { + status: 'missing', + manifestPath, + fixtures: [], + errors: ['active production regression manifest is missing'], + }; + } + return { + status: 'invalid', + manifestPath, + fixtures: [], + errors: [`active production regression manifest is invalid: ${error.message}`], + }; + } + + const fixtureIds = manifest?.fixture_ids; + if (!Array.isArray(fixtureIds) || fixtureIds.length === 0) { + return { + status: 'invalid', + manifestPath, + fixtures: [], + errors: ['active manifest must contain at least one fixture_id'], + }; + } + if (new Set(fixtureIds).size !== fixtureIds.length) { + return { + status: 'invalid', + manifestPath, + fixtures: [], + errors: ['active manifest contains duplicate fixture_ids'], + }; + } + + const fixtures = []; + const errors = []; + const catalogIds = new Set(catalog.map((scenario) => scenario.id)); + for (const fixtureId of fixtureIds) { + if (!FIXTURE_ID_PATTERN.test(fixtureId)) { + errors.push(`invalid fixture id ${fixtureId}`); + continue; + } + const matches = []; + for (const bucket of ['agent-run', 'page-data', 'memory', 'wechat', 'files']) { + const candidate = path.join(corpusRoot, bucket, `${fixtureId}.json`); + try { + await fs.access(candidate); + matches.push(candidate); + } catch { + // The fixture belongs to exactly one approved regression bucket. + } + } + if (matches.length !== 1) { + errors.push(`${fixtureId} must exist in exactly one regression bucket`); + continue; + } + try { + const fixture = JSON.parse(await fs.readFile(matches[0], 'utf8')); + const validation = validateRegressionFixture(fixture, { catalogIds }); + if (fixture.id !== fixtureId) validation.errors.push(`fixture id does not match ${fixtureId}`); + if (validation.errors.length) { + errors.push(`${fixtureId}: ${validation.errors.join('; ')}`); + } else { + fixtures.push({ ...fixture, file: path.relative(root, matches[0]) }); + } + } catch (error) { + errors.push(`${fixtureId}: invalid JSON (${error.message})`); + } + } + return { + status: errors.length ? 'invalid' : 'ready', + manifestPath, + fixtures, + errors, + }; +} diff --git a/release-gate/regression-corpus.test.mjs b/release-gate/regression-corpus.test.mjs new file mode 100644 index 0000000..ece9209 --- /dev/null +++ b/release-gate/regression-corpus.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { loadScenarioCatalog } from './catalog.mjs'; +import { + loadActiveRegressionCorpus, + validateRegressionFixture, +} from './regression-corpus.mjs'; + +function fixture(overrides = {}) { + return { + id: 'PRC-AGENT-001', + scenario_ids: ['AGENT-11'], + failure_signature: 'worker heartbeat expired', + synthetic_intent: '生成一个合成测试页面', + source_batch_id: 'audit-2026-07-26', + evidence_digest: 'a'.repeat(64), + contains_personal_data: false, + privacy_review: { + reviewed_by: 'release-reviewer', + reviewed_at: '2026-07-26T10:00:00.000Z', + }, + automation: { + command: ['node', '--test', 'fixture.test.mjs'], + }, + ...overrides, + }; +} + +test('regression fixture requires scenario mapping, privacy review, and executable replay', async () => { + const catalog = await loadScenarioCatalog(); + assert.deepEqual( + validateRegressionFixture(fixture(), { + catalogIds: new Set(catalog.map((scenario) => scenario.id)), + }), + { valid: true, errors: [] }, + ); +}); + +test('regression fixture rejects raw production identity and content fields', () => { + const validation = validateRegressionFixture(fixture({ + raw_chat: 'private', + openid: 'should-not-exist', + contains_personal_data: true, + })); + assert.equal(validation.valid, false); + assert.match(validation.errors.join('\n'), /contains_personal_data/); + assert.match(validation.errors.join('\n'), /raw_chat/); + assert.match(validation.errors.join('\n'), /openid/); +}); + +test('missing active production regression manifest fails closed', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-regression-corpus-')); + try { + const catalog = await loadScenarioCatalog(); + const result = await loadActiveRegressionCorpus({ root, catalog }); + assert.equal(result.status, 'missing'); + assert.equal(result.fixtures.length, 0); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/release-gate/release-runtime-safety.test.mjs b/release-gate/release-runtime-safety.test.mjs new file mode 100644 index 0000000..5ccf439 --- /dev/null +++ b/release-gate/release-runtime-safety.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const releaseScript = fs.readFileSync( + new URL('../scripts/release-portal-runtime-prod.sh', import.meta.url), + 'utf8', +); + +test('REL-08 all nine goosed runtimes are remounted and health/identity checked after live swap', () => { + assert.match(releaseScript, /containers\[@\].*!= 9/); + assert.match(releaseScript, /seq 18006 18014/); + assert.match(releaseScript, /up -d --force-recreate/); + assert.match(releaseScript, /\/opt\/portal\/mindspace-sandbox-mcp\.mjs/); + assert.match(releaseScript, /remount_goosed_after_live_swap/); + assert.match(releaseScript, /ensure_portal_tunnel/); + const swap = releaseScript.indexOf('mv "${RUNTIME_DIR}" "${APP_DIR}"'); + const remount = releaseScript.indexOf('remount_goosed_after_live_swap', swap); + const portalStart = releaseScript.indexOf('say "启动新的 Portal runtime"', remount); + assert.ok(swap >= 0 && remount > swap && portalStart > remount); +}); + +test('REL-09 release script has an error trap and old-runtime restart primitives', () => { + assert.match(releaseScript, /trap 'rollback' ERR/); + assert.match(releaseScript, /health check failed: portal=/); + assert.match(releaseScript, /launchctl kickstart -k/); + assert.match(releaseScript, /run-memind-portal-prod\.sh/); +}); + +test('REL-10 validates upload before switch and snapshots live plus persistent data first', () => { + const checksum = releaseScript.indexOf('shasum -a 256 -c'); + const fullBackup = releaseScript.indexOf('say "备份当前 live 全目录"'); + const persistBackup = releaseScript.indexOf('say "单独备份持久目录"'); + const stopOld = releaseScript.indexOf('say "停止旧 Portal 服务"'); + const swap = releaseScript.indexOf('mv "${APP_DIR}" "${OLD_LIVE_DIR}"'); + assert.ok(checksum >= 0); + assert.ok(fullBackup > checksum); + assert.ok(persistBackup > fullBackup); + assert.ok(stopOld > persistBackup); + assert.ok(swap > stopOld); + assert.match(releaseScript, /set -euo pipefail/); + assert.match(releaseScript, /trap 'rollback' ERR/); +}); diff --git a/release-gate/release-script.test.mjs b/release-gate/release-script.test.mjs new file mode 100644 index 0000000..d43de39 --- /dev/null +++ b/release-gate/release-script.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +const ROOT = path.resolve(new URL('..', import.meta.url).pathname); + +test('production release verifies gate report before any 103 connection', async () => { + const source = await fs.readFile( + path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'), + 'utf8', + ); + const gateIndex = source.indexOf('verify-release-gate-report.mjs'); + const preflightIndex = source.indexOf('执行 103 只读预检'); + const uploadIndex = source.indexOf('上传 Portal runtime 到 103'); + assert.ok(gateIndex > 0, 'missing gate verifier'); + assert.ok(preflightIndex > gateIndex, '103 preflight must run after gate verification'); + assert.ok(uploadIndex > preflightIndex, 'upload must run after preflight'); + assert.match(source, /生产发布守门员禁止 --skip-tests/); + assert.match(source, /禁止 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS/); +}); + +test('runtime builder removes every persisted path forbidden by artifact policy', async () => { + const source = await fs.readFile( + path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'), + 'utf8', + ); + assert.match(source, /removeForbiddenPortalRuntimePaths/); + assert.match(source, /removeForbiddenPortalRuntimePaths\(runtimeRoot\)/); +}); + +test('runtime builder materializes and declares required Linux ARM64 native packages', async () => { + const source = await fs.readFile( + path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'), + 'utf8', + ); + assert.match(source, /@resvg\/resvg-js-linux-arm64-gnu/); + assert.match(source, /@node-rs\/argon2-linux-arm64-gnu/); + assert.match(source, /installLinuxArm64NativePackages\(\)/); + assert.match(source, /optionalDependencies: runtimeOptionalDependencies/); + assert.match(source, /缺少原生 \.node 文件/); +}); + +test('packaged runtime gate isolates persistent roots and rejects artifact mutation', async () => { + const localStackSource = await fs.readFile( + path.join(ROOT, 'release-gate', 'local-stack.mjs'), + 'utf8', + ); + assert.match(localStackSource, /MEMIND_PORTAL_H5_ROOT: resolvedPortalRoot/); + assert.match(localStackSource, /MEMIND_DEEPSEEK_DISABLE_THINKING: '0'/); + assert.match(localStackSource, /H5_USERS_ROOT: usersRoot/); + assert.match(localStackSource, /MINDSPACE_STORAGE_ROOT: storageRoot/); + assert.match(localStackSource, /MEMIND_SHARED_PUBLISH_ROOT: publishRoot/); + + for (const scriptName of [ + 'run-release-gate-runtime-cold-start.mjs', + 'run-release-gate-runtime-upgrade.mjs', + ]) { + const scriptSource = await fs.readFile(path.join(ROOT, 'scripts', scriptName), 'utf8'); + assert.match(scriptSource, /const artifactBefore = await hashArtifact\(runtimeRoot\)/); + assert.match(scriptSource, /const artifactAfter = await hashArtifact\(runtimeRoot\)/); + assert.match(scriptSource, /artifactAfter\.sha256 !== artifactBefore\.sha256/); + } +}); + +test('production release rejects --skip-tests before repository or network preflight', () => { + const result = spawnSync( + 'bash', + [path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'), '--skip-tests'], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /禁止 --skip-tests/); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /103 只读预检/); +}); diff --git a/release-gate/report.mjs b/release-gate/report.mjs new file mode 100644 index 0000000..9bfcc8f --- /dev/null +++ b/release-gate/report.mjs @@ -0,0 +1,275 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { expectedScenarioIds, isScenarioExemptable } from './catalog.mjs'; + +const TERMINAL_STATUSES = new Set([ + 'passed', + 'failed', + 'not_applicable', + 'skipped', + 'blocked', + 'unknown', +]); + +function iso(value) { + return new Date(value).toISOString(); +} + +export function createEnvironmentFingerprint(extra = {}) { + const environment = { + node: process.version, + platform: process.platform, + arch: process.arch, + release: os.release(), + ...extra, + }; + const fingerprint = crypto + .createHash('sha256') + .update(JSON.stringify(environment)) + .digest('hex'); + return { environment, fingerprint }; +} + +export function summarizeScenarios(scenarios) { + const summary = { + required: scenarios.length, + passed: 0, + not_applicable: 0, + failed: 0, + skipped: 0, + blocked: 0, + unknown: 0, + cleanup_failed: 0, + }; + for (const scenario of scenarios) { + if (!TERMINAL_STATUSES.has(scenario.status)) { + throw new Error(`Invalid scenario status for ${scenario.id}: ${scenario.status}`); + } + summary[scenario.status] += 1; + if (scenario.cleanup_status === 'failed') { + summary.cleanup_failed += 1; + } + } + return summary; +} + +export function createGateReport({ + commitSha, + branch, + artifactSha256, + artifact, + scenarios, + mode = 'all', + startedAt = new Date(), + completedAt = new Date(), + maxAgeMs = 4 * 60 * 60 * 1000, + environment = createEnvironmentFingerprint(), +}) { + const summary = summarizeScenarios(scenarios); + return { + schema_version: 1, + mode, + commit_sha: commitSha, + branch, + artifact_sha256: artifactSha256, + artifact, + started_at: iso(startedAt), + completed_at: iso(completedAt), + expires_at: iso(new Date(new Date(completedAt).getTime() + maxAgeMs)), + environment: environment.environment, + environment_fingerprint: environment.fingerprint, + summary, + scenarios, + exemptions: scenarios + .filter((scenario) => scenario.status === 'not_applicable') + .map((scenario) => scenario.exemption), + approved_for_release: false, + }; +} + +function validateExemption(scenario, commitSha, errors) { + if (!isScenarioExemptable(scenario.id)) { + errors.push(`${scenario.id} is never exemptable`); + return; + } + const exemption = scenario.exemption; + if (!exemption || exemption.scenario_id !== scenario.id) { + errors.push(`${scenario.id} exemption is missing or mismatched`); + return; + } + if (exemption.commit_sha !== commitSha) { + errors.push(`${scenario.id} exemption commit does not match report`); + } + for (const field of ['reason', 'reviewed_by', 'reviewed_at']) { + if (!String(exemption[field] ?? '').trim()) { + errors.push(`${scenario.id} exemption is missing ${field}`); + } + } + for (const field of ['changed_paths_checked', 'dependency_evidence']) { + if (!Array.isArray(exemption[field]) || exemption[field].length === 0) { + errors.push(`${scenario.id} exemption is missing ${field}`); + } + } + if (exemption.reviewed_at && Number.isNaN(Date.parse(exemption.reviewed_at))) { + errors.push(`${scenario.id} exemption reviewed_at is invalid`); + } +} + +export function validateGateReport(report, { + expectedCommit, + expectedArtifactSha256, + expectedBranch = 'main', + now = new Date(), + requireFullCatalog = true, +} = {}) { + const errors = []; + if (report?.schema_version !== 1) errors.push('schema_version must be 1'); + if (report?.mode !== 'all' && requireFullCatalog) errors.push('release report mode must be all'); + if (!/^[0-9a-f]{40}$/i.test(report?.commit_sha ?? '')) errors.push('commit_sha must be a full SHA'); + if (expectedCommit && report?.commit_sha !== expectedCommit) errors.push('commit_sha does not match candidate'); + if (expectedBranch && report?.branch !== expectedBranch) errors.push(`branch must be ${expectedBranch}`); + if (!/^[0-9a-f]{64}$/i.test(report?.artifact_sha256 ?? '')) { + errors.push('artifact_sha256 must be a SHA256'); + } + if (expectedArtifactSha256 && report?.artifact_sha256 !== expectedArtifactSha256) { + errors.push('artifact_sha256 does not match candidate artifact'); + } + if (!/^[0-9a-f]{64}$/i.test(report?.environment_fingerprint ?? '')) { + errors.push('environment_fingerprint must be a SHA256'); + } + + const completedAt = Date.parse(report?.completed_at ?? ''); + const expiresAt = Date.parse(report?.expires_at ?? ''); + if (!Number.isFinite(completedAt) || !Number.isFinite(expiresAt)) { + errors.push('report timestamps are invalid'); + } else { + if (expiresAt <= completedAt) errors.push('report expiry must be after completion'); + if (expiresAt - completedAt > 4 * 60 * 60 * 1000) errors.push('report validity exceeds four hours'); + if (new Date(now).getTime() > expiresAt) errors.push('report has expired'); + } + + if (!Array.isArray(report?.scenarios)) { + errors.push('scenarios must be an array'); + } else { + const ids = report.scenarios.map((scenario) => scenario.id); + const unique = new Set(ids); + if (requireFullCatalog) { + const expected = expectedScenarioIds(); + const expectedSet = new Set(expected); + if (ids.length !== expected.length || unique.size !== expected.length) { + errors.push(`report must contain ${expected.length} unique scenarios`); + } + const missing = expected.filter((id) => !unique.has(id)); + const unexpected = ids.filter((id) => !expectedSet.has(id)); + if (missing.length) errors.push(`report is missing scenarios: ${missing.join(',')}`); + if (unexpected.length) errors.push(`report has unexpected scenarios: ${unexpected.join(',')}`); + } + + for (const scenario of report.scenarios) { + if (!TERMINAL_STATUSES.has(scenario.status)) { + errors.push(`${scenario.id} has invalid status ${scenario.status}`); + continue; + } + if (scenario.status === 'not_applicable') { + validateExemption(scenario, report.commit_sha, errors); + } else if (scenario.status !== 'passed') { + errors.push(`${scenario.id} is ${scenario.status}`); + } + if (scenario.cleanup_status === 'failed') { + errors.push(`${scenario.id} cleanup failed`); + } + } + + try { + const actualSummary = summarizeScenarios(report.scenarios); + if (JSON.stringify(actualSummary) !== JSON.stringify(report.summary)) { + errors.push('summary does not match scenario statuses'); + } + if (actualSummary.failed || actualSummary.skipped || actualSummary.blocked + || actualSummary.unknown || actualSummary.cleanup_failed) { + errors.push('report contains blocking results'); + } + if (actualSummary.passed + actualSummary.not_applicable !== actualSummary.required) { + errors.push('passed + not_applicable must equal required'); + } + } catch (error) { + errors.push(error.message); + } + } + + if (!Array.isArray(report?.exemptions)) errors.push('exemptions must be an array'); + return { valid: errors.length === 0, errors }; +} + +function renderMarkdown(report) { + const lines = [ + '# Production Release Gate Report', + '', + `- Mode: ${report.mode}`, + `- Commit: \`${report.commit_sha}\``, + `- Branch: \`${report.branch}\``, + `- Artifact SHA256: \`${report.artifact_sha256}\``, + `- Completed: ${report.completed_at}`, + `- Expires: ${report.expires_at}`, + '', + '| Result | Count |', + '|---|---:|', + ...Object.entries(report.summary).map(([key, value]) => `| ${key} | ${value} |`), + '', + '| Scenario | Status | Evidence |', + '|---|---|---|', + ...report.scenarios.map( + (scenario) => `| ${scenario.id} | ${scenario.status} | ${scenario.evidence?.join('
') ?? ''} |`, + ), + '', + ]; + return lines.join('\n'); +} + +function renderJunit(report) { + const failures = report.scenarios.filter( + (scenario) => !['passed', 'not_applicable'].includes(scenario.status), + ).length; + const cases = report.scenarios.map((scenario) => { + const name = `${scenario.id} ${scenario.name ?? ''}`.replaceAll('"', '"'); + if (scenario.status === 'passed') { + return ` `; + } + if (scenario.status === 'not_applicable') { + return ` `; + } + return ` `; + }); + return [ + '', + ``, + ...cases, + '', + '', + ].join('\n'); +} + +export async function writeGateReport(report, outputDir) { + await fs.mkdir(outputDir, { recursive: true }); + await Promise.all([ + fs.mkdir(path.join(outputDir, 'logs'), { recursive: true }), + fs.mkdir(path.join(outputDir, 'scenarios'), { recursive: true }), + fs.mkdir(path.join(outputDir, 'screenshots'), { recursive: true }), + ]); + await Promise.all([ + fs.writeFile(path.join(outputDir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`), + fs.writeFile(path.join(outputDir, 'report.md'), renderMarkdown(report)), + fs.writeFile(path.join(outputDir, 'junit.xml'), renderJunit(report)), + fs.writeFile( + path.join(outputDir, 'environment.json'), + `${JSON.stringify({ + fingerprint: report.environment_fingerprint, + ...report.environment, + }, null, 2)}\n`, + ), + fs.writeFile(path.join(outputDir, 'artifact.sha256'), `${report.artifact_sha256}\n`), + ]); +} diff --git a/release-gate/report.test.mjs b/release-gate/report.test.mjs new file mode 100644 index 0000000..b671596 --- /dev/null +++ b/release-gate/report.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { loadScenarioCatalog } from './catalog.mjs'; +import { + createGateReport, + validateGateReport, +} from './report.mjs'; + +const COMMIT = 'a'.repeat(40); +const ARTIFACT = 'b'.repeat(64); + +async function passingReport() { + const catalog = await loadScenarioCatalog(); + return createGateReport({ + commitSha: COMMIT, + branch: 'main', + artifactSha256: ARTIFACT, + artifact: { path: '.runtime/portal', kind: 'directory-tree' }, + scenarios: catalog.map((scenario) => ({ + id: scenario.id, + name: scenario.name, + status: 'passed', + cleanup_status: 'not_required', + evidence: ['fixture'], + })), + completedAt: new Date('2026-07-26T10:00:00.000Z'), + }); +} + +test('complete report bound to main commit and artifact passes validation', async () => { + const report = await passingReport(); + const result = validateGateReport(report, { + expectedCommit: COMMIT, + expectedArtifactSha256: ARTIFACT, + now: new Date('2026-07-26T11:00:00.000Z'), + }); + assert.deepEqual(result, { valid: true, errors: [] }); +}); + +test('unknown scenario and mismatched summary fail closed', async () => { + const report = await passingReport(); + report.scenarios[0].status = 'unknown'; + const result = validateGateReport(report, { + expectedCommit: COMMIT, + expectedArtifactSha256: ARTIFACT, + now: new Date('2026-07-26T11:00:00.000Z'), + }); + assert.equal(result.valid, false); + assert.match(result.errors.join('\n'), /REL-01 is unknown/); + assert.match(result.errors.join('\n'), /summary does not match/); +}); + +test('non-exemptable scenario cannot be marked not_applicable', async () => { + const report = await passingReport(); + const scenario = report.scenarios.find((item) => item.id === 'COMP-09'); + scenario.status = 'not_applicable'; + scenario.exemption = { + scenario_id: 'COMP-09', + commit_sha: COMMIT, + reason: 'fixture', + changed_paths_checked: ['README.md'], + dependency_evidence: ['none'], + reviewed_by: 'reviewer', + reviewed_at: '2026-07-26T10:00:00.000Z', + }; + report.summary.passed -= 1; + report.summary.not_applicable += 1; + const result = validateGateReport(report, { + expectedCommit: COMMIT, + expectedArtifactSha256: ARTIFACT, + now: new Date('2026-07-26T11:00:00.000Z'), + }); + assert.equal(result.valid, false); + assert.match(result.errors.join('\n'), /COMP-09 is never exemptable/); +}); + +test('reviewed exemption is accepted only for an exemptable scenario', async () => { + const report = await passingReport(); + const scenario = report.scenarios.find((item) => item.id === 'SEARCH-04'); + scenario.status = 'not_applicable'; + scenario.exemption = { + scenario_id: 'SEARCH-04', + commit_sha: COMMIT, + reason: 'GitHub Search is disabled and no shared gateway path changed', + changed_paths_checked: ['docs/example.md'], + dependency_evidence: ['impact-map.json'], + reviewed_by: 'independent-reviewer', + reviewed_at: '2026-07-26T10:00:00.000Z', + }; + report.exemptions = [scenario.exemption]; + report.summary.passed -= 1; + report.summary.not_applicable += 1; + const result = validateGateReport(report, { + expectedCommit: COMMIT, + expectedArtifactSha256: ARTIFACT, + now: new Date('2026-07-26T11:00:00.000Z'), + }); + assert.deepEqual(result, { valid: true, errors: [] }); +}); + +test('expired report or artifact mismatch is rejected', async () => { + const report = await passingReport(); + const result = validateGateReport(report, { + expectedCommit: COMMIT, + expectedArtifactSha256: 'c'.repeat(64), + now: new Date('2026-07-27T11:00:00.000Z'), + }); + assert.equal(result.valid, false); + assert.match(result.errors.join('\n'), /artifact_sha256 does not match/); + assert.match(result.errors.join('\n'), /report has expired/); +}); diff --git a/release-gate/runner.mjs b/release-gate/runner.mjs new file mode 100644 index 0000000..03d98c7 --- /dev/null +++ b/release-gate/runner.mjs @@ -0,0 +1,257 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { assertPortalRuntimePath, hashArtifact, inspectPortalRuntime } from './artifact.mjs'; +import { loadScenarioCatalog } from './catalog.mjs'; +import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs'; +import { loadActiveRegressionCorpus } from './regression-corpus.mjs'; +import { createGateReport, writeGateReport } from './report.mjs'; +import { assertSafeGateEnvironment, assertSafePortalBase } from './safety.mjs'; + +const ROOT = path.resolve(new URL('..', import.meta.url).pathname); +const MODES = new Set(['deterministic', 'scenarios', 'browser', 'providers', 'upgrade', 'all']); + +async function git(...args) { + const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 }); + if (result.code !== 0) throw new Error(`git ${args.join(' ')} failed`); + return result.stdout.trim(); +} + +export function parseRunnerArgs(argv) { + const options = { + mode: 'all', + artifact: null, + portalBase: 'http://127.0.0.1:8081', + reportRoot: path.join(ROOT, '.release-gate'), + timeoutMs: 15 * 60 * 1000, + suiteConcurrency: Number(process.env.RELEASE_GATE_SUITE_CONCURRENCY ?? 4), + }; + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--mode') options.mode = argv[++index] ?? ''; + else if (arg === '--artifact') options.artifact = path.resolve(argv[++index] ?? ''); + else if (arg === '--portal-base') options.portalBase = argv[++index] ?? ''; + else if (arg === '--report-root') options.reportRoot = path.resolve(argv[++index] ?? ''); + else if (arg === '--timeout-ms') options.timeoutMs = Number(argv[++index]); + else if (arg === '--suite-concurrency') options.suiteConcurrency = Number(argv[++index]); + else if (arg === '-h' || arg === '--help') options.help = true; + else throw new Error(`Unknown argument: ${arg}`); + } + if (!MODES.has(options.mode)) throw new Error(`Invalid release gate mode: ${options.mode}`); + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { + throw new Error('timeout-ms must be a positive number'); + } + if ( + !Number.isFinite(options.suiteConcurrency) + || options.suiteConcurrency <= 0 + || options.suiteConcurrency > 16 + ) { + throw new Error('suite-concurrency must be between 1 and 16'); + } + options.suiteConcurrency = Math.floor(options.suiteConcurrency); + return options; +} + +export async function runWithConcurrency(items, concurrency, worker) { + const results = new Array(items.length); + let nextIndex = 0; + async function runWorker() { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await worker(items[index], index); + } + } + await Promise.all( + Array.from( + { length: Math.min(items.length, Math.max(1, concurrency)) }, + () => runWorker(), + ), + ); + return results; +} + +export async function runCommand(command, args, { + cwd = ROOT, + timeoutMs = 15 * 60 * 1000, + env = process.env, +} = {}) { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + }, timeoutMs); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', (error) => { + clearTimeout(timer); + resolve({ code: 1, stdout, stderr: `${stderr}${error.message}`, timedOut }); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code: code ?? 1, stdout, stderr, timedOut }); + }); + }); +} + +function scenarioResult(scenario) { + return { + id: scenario.id, + name: scenario.name, + status: 'unknown', + cleanup_status: 'not_required', + evidence: [], + reason: 'automation_not_implemented', + }; +} + +async function runSuite(suite, outputDir, timeoutMs) { + const [command, ...args] = suite.command; + const result = await runCommand(command, args, { cwd: ROOT, timeoutMs }); + const logDir = path.join(outputDir, 'logs'); + await fs.mkdir(logDir, { recursive: true }); + const logPath = path.join(logDir, `${suite.id}.log`); + await fs.writeFile( + logPath, + [ + `$ ${[command, ...args].join(' ')}`, + `exit_code=${result.code}`, + `timed_out=${result.timedOut}`, + '', + result.stdout, + result.stderr, + ].join('\n'), + ); + return { ...result, logPath: path.relative(outputDir, logPath) }; +} + +async function applyRepositoryChecks(results, artifactPath) { + const byId = new Map(results.map((result) => [result.id, result])); + const branch = await git('branch', '--show-current'); + const status = await git('status', '--porcelain=v1', '--untracked-files=all'); + const originMain = await runCommand( + 'git', + ['rev-parse', '--verify', 'origin/main'], + { cwd: ROOT, timeoutMs: 30_000 }, + ); + const remoteSha = originMain.code === 0 ? originMain.stdout.trim() : null; + const headSha = await git('rev-parse', 'HEAD'); + const rel01 = byId.get('REL-01'); + rel01.status = branch === 'main' && status === '' ? 'passed' : 'failed'; + rel01.reason = rel01.status === 'passed' ? null : 'candidate_must_be_clean_main'; + rel01.evidence.push(`branch=${branch || 'detached'}`, `worktree_clean=${status === ''}`); + + const ciStatus = String(process.env.MEMIND_RELEASE_CI_STATUS ?? '').trim().toLowerCase(); + const rel02 = byId.get('REL-02'); + rel02.status = remoteSha === headSha && ciStatus === 'success' ? 'passed' : 'failed'; + rel02.reason = rel02.status === 'passed' + ? null + : 'candidate_must_equal_origin_main_with_successful_ci'; + rel02.evidence.push( + `head=${headSha}`, + `origin_main=${remoteSha ?? 'missing'}`, + `ci_status=${ciStatus || 'missing'}`, + ); + + if (!artifactPath) return null; + const artifact = await hashArtifact(artifactPath); + const rel03 = byId.get('REL-03'); + rel03.reason = 'repeat_build_comparison_not_implemented'; + rel03.evidence.push(`artifact_sha256=${artifact.sha256}`, `artifact_kind=${artifact.kind}`); + + const inspection = await inspectPortalRuntime(artifactPath); + const rel04 = byId.get('REL-04'); + rel04.status = inspection.passed ? 'passed' : 'failed'; + rel04.reason = rel04.status === 'passed' ? null : 'artifact_incomplete_or_contains_forbidden_paths'; + rel04.evidence.push( + `missing=${inspection.missing.join(',') || 'none'}`, + `forbidden=${inspection.forbidden.join(',') || 'none'}`, + ); + + const rel11 = byId.get('REL-11'); + rel11.status = inspection.missing.length === 0 ? 'unknown' : 'failed'; + rel11.reason = rel11.status === 'failed' + ? 'runtime_dependency_closure_incomplete' + : 'runtime_container_start_smoke_not_implemented'; + rel11.evidence.push(`missing=${inspection.missing.join(',') || 'none'}`); + return artifact; +} + +function suiteIsInMode(suite, mode) { + return mode === 'all' || suite.mode === mode; +} + +export async function executeReleaseGate(options) { + const startedAt = new Date(); + assertSafeGateEnvironment({ targets: [options.portalBase] }); + assertSafePortalBase(options.portalBase); + if (options.artifact) { + options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT }); + } + const catalog = await loadScenarioCatalog({ root: ROOT }); + validateAutomationSuites(catalog); + + const commitSha = await git('rev-parse', 'HEAD'); + const branch = await git('branch', '--show-current'); + const outputDir = options.mode === 'all' + ? path.join(options.reportRoot, commitSha) + : path.join(options.reportRoot, commitSha, 'partials', options.mode); + const results = catalog.map(scenarioResult); + const byId = new Map(results.map((result) => [result.id, result])); + const artifact = await applyRepositoryChecks(results, options.artifact); + const regressionCorpus = await loadActiveRegressionCorpus({ root: ROOT, catalog }); + const comp09 = byId.get('COMP-09'); + comp09.reason = regressionCorpus.status === 'ready' + ? 'production_regression_replay_not_implemented' + : `production_regression_corpus_${regressionCorpus.status}`; + comp09.evidence.push( + `manifest=${path.relative(ROOT, regressionCorpus.manifestPath)}`, + `fixtures=${regressionCorpus.fixtures.length}`, + ...regressionCorpus.errors.map((error) => `error=${error}`), + ); + + const suites = AUTOMATION_SUITES.filter((candidate) => suiteIsInMode(candidate, options.mode)); + const executions = await runWithConcurrency( + suites, + options.suiteConcurrency, + (suite) => runSuite(suite, outputDir, options.timeoutMs), + ); + for (let index = 0; index < suites.length; index += 1) { + const suite = suites[index]; + const execution = executions[index]; + for (const scenarioId of suite.scenarios) { + const scenario = byId.get(scenarioId); + scenario.status = execution.code === 0 && !execution.timedOut ? 'passed' : 'failed'; + scenario.reason = scenario.status === 'passed' ? null : 'automation_suite_failed'; + scenario.evidence.push( + `suite=${suite.id}`, + `log=${execution.logPath}`, + ...suite.cases[scenarioId].map((assertedCase) => `asserted_case=${assertedCase}`), + ); + } + } + + const report = createGateReport({ + commitSha, + branch, + artifactSha256: artifact?.sha256 ?? '0'.repeat(64), + artifact: artifact + ? { path: path.relative(ROOT, options.artifact), kind: artifact.kind, files: artifact.files, bytes: artifact.bytes } + : { path: null, kind: 'missing' }, + scenarios: results, + mode: options.mode, + startedAt, + completedAt: new Date(), + }); + await writeGateReport(report, outputDir); + return { report, outputDir }; +} diff --git a/release-gate/runner.test.mjs b/release-gate/runner.test.mjs new file mode 100644 index 0000000..6b60cce --- /dev/null +++ b/release-gate/runner.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseRunnerArgs, runWithConcurrency } from './runner.mjs'; + +test('runner accepts a bounded suite concurrency and rejects unsafe values', () => { + assert.equal( + parseRunnerArgs(['node', 'runner', '--suite-concurrency', '3']).suiteConcurrency, + 3, + ); + assert.throws( + () => parseRunnerArgs(['node', 'runner', '--suite-concurrency', '0']), + /between 1 and 16/, + ); + assert.throws( + () => parseRunnerArgs(['node', 'runner', '--suite-concurrency', '17']), + /between 1 and 16/, + ); +}); + +test('runWithConcurrency preserves result order and never exceeds its worker budget', async () => { + let active = 0; + let peak = 0; + const results = await runWithConcurrency([40, 5, 20, 1], 2, async (delay, index) => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, delay)); + active -= 1; + return `result-${index}`; + }); + assert.deepEqual(results, ['result-0', 'result-1', 'result-2', 'result-3']); + assert.equal(peak, 2); +}); diff --git a/release-gate/runtime-worker-entrypoint.test.mjs b/release-gate/runtime-worker-entrypoint.test.mjs new file mode 100644 index 0000000..ad0339d --- /dev/null +++ b/release-gate/runtime-worker-entrypoint.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +test('DeepSeek proxy main guard is explicit so bundled worker cannot bind 18036', () => { + const source = fs.readFileSync(new URL('../deepseek-no-think-proxy.mjs', import.meta.url), 'utf8'); + assert.match(source, /MEMIND_DEEPSEEK_PROXY_ENTRYPOINT === '1'/); + assert.match(source, /startDeepseekNoThinkProxy\(\)\.catch/); +}); diff --git a/release-gate/safety.mjs b/release-gate/safety.mjs new file mode 100644 index 0000000..274f88f --- /dev/null +++ b/release-gate/safety.mjs @@ -0,0 +1,80 @@ +const FORBIDDEN_HOSTS = new Set([ + '58.38.22.103', + 'm.tkmind.cn', + 'plaza.tkmind.cn', +]); + +const TARGET_ENV_KEYS = [ + 'STUDIO_HOST', + 'STUDIO_HEALTH_URL', + 'H5_PUBLIC_BASE_URL', + 'MINDSPACE_BASE_URL', + 'DATABASE_URL', + 'MINDSPACE_USERDATA_PG_URL', + 'MYSQL_HOST', + 'PGHOST', + 'REDIS_URL', + 'SEARXNG_URL', + 'DEEP_SEARCH_URL', +]; + +function extractHostname(value) { + const text = String(value ?? '').trim(); + if (!text) return ''; + try { + return new URL(text.includes('://') ? text : `tcp://${text}`).hostname.toLowerCase(); + } catch { + return text.split('/')[0].split(':')[0].toLowerCase(); + } +} + +export function isProductionTarget(value) { + const text = String(value ?? '').toLowerCase(); + const hostname = extractHostname(text); + return FORBIDDEN_HOSTS.has(hostname) + || hostname.endsWith('.tkmind.cn') + || text.includes('58.38.22.103') + || text.includes('/users/john/project/memind'); +} + +export function assertNonProductionTarget(value, label = 'target') { + if (isProductionTarget(value)) { + throw new Error(`Release gate refused production ${label}`); + } +} + +export function assertSafePortalBase(portalBase) { + assertNonProductionTarget(portalBase, 'Portal base URL'); + const parsed = new URL(portalBase); + if (!['127.0.0.1', 'localhost', '::1', '[::1]'].includes(parsed.hostname)) { + throw new Error('Release gate Portal base must use an isolated loopback host'); + } + return parsed.toString().replace(/\/$/, ''); +} + +export function assertSafeGateEnvironment({ + env = process.env, + targets = [], +} = {}) { + const unsafeKeys = []; + for (const key of TARGET_ENV_KEYS) { + if (env[key] && isProductionTarget(env[key])) { + unsafeKeys.push(key); + } + } + for (const [index, target] of targets.entries()) { + if (target && isProductionTarget(target)) { + unsafeKeys.push(`target[${index}]`); + } + } + if (unsafeKeys.length) { + throw new Error( + `Release gate refused production-linked configuration: ${unsafeKeys.join(', ')}`, + ); + } +} + +export const productionTargetPolicy = Object.freeze({ + forbiddenHosts: [...FORBIDDEN_HOSTS], + inspectedEnvironmentKeys: [...TARGET_ENV_KEYS], +}); diff --git a/release-gate/safety.test.mjs b/release-gate/safety.test.mjs new file mode 100644 index 0000000..2ba11f0 --- /dev/null +++ b/release-gate/safety.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + assertNonProductionTarget, + assertSafeGateEnvironment, + assertSafePortalBase, + isProductionTarget, +} from './safety.mjs'; + +test('production IP, public domains, and production paths are refused', () => { + for (const target of [ + '58.38.22.103', + 'ssh://58.38.22.103', + 'https://m.tkmind.cn', + 'https://plaza.tkmind.cn/page', + '/Users/john/Project/Memind', + ]) { + assert.equal(isProductionTarget(target), true, target); + assert.throws(() => assertNonProductionTarget(target), /refused production/); + } +}); + +test('scenario Portal target must be loopback', () => { + assert.equal(assertSafePortalBase('http://127.0.0.1:8081'), 'http://127.0.0.1:8081'); + assert.equal(assertSafePortalBase('http://localhost:9000/'), 'http://localhost:9000'); + assert.throws(() => assertSafePortalBase('http://192.168.1.9:8081'), /isolated loopback/); + assert.throws(() => assertSafePortalBase('https://m.tkmind.cn'), /production/); +}); + +test('unsafe environment keys are reported without exposing values', () => { + assert.throws( + () => assertSafeGateEnvironment({ + env: { + DATABASE_URL: 'postgres://secret@58.38.22.103/prod', + STUDIO_HOST: '58.38.22.103', + }, + }), + (error) => { + assert.match(error.message, /DATABASE_URL/); + assert.match(error.message, /STUDIO_HOST/); + assert.doesNotMatch(error.message, /secret/); + return true; + }, + ); +}); diff --git a/scenarios/long-content-rich-page.json b/scenarios/long-content-rich-page.json new file mode 100644 index 0000000..c5c50f8 --- /dev/null +++ b/scenarios/long-content-rich-page.json @@ -0,0 +1,27 @@ +{ + "id": "long-content-rich-page", + "name": "长内容移动端页面交付", + "description": "验证结构化长内容能够生成完整、可访问、适配移动端的公开页面", + "account": { + "username": "john2", + "password": "888888" + }, + "steps": [ + { + "action": "chat", + "label": "生成结构化长内容页面", + "message": "请创建一个全新的移动端友好页面,保存为 public/long-content-rich-e2e.html。主题是苏州两日深度游,必须包含行程、交通、住宿、美食、预算、注意事项六个清晰章节,并直接返回公开链接。不要修改已有页面。", + "expect": { + "assistantMinChars": 40, + "timeoutMs": 600000, + "replyKeywords": ["苏州", "行程", "预算"], + "page": { + "keywords": ["苏州", "行程", "预算"], + "requirePublicLink": true, + "requireHttp200": true, + "requireMindspaceCover": false + } + } + } + ] +} diff --git a/scenarios/production-regressions/agent-run/PRC-REL-001.json b/scenarios/production-regressions/agent-run/PRC-REL-001.json new file mode 100644 index 0000000..1e42ed1 --- /dev/null +++ b/scenarios/production-regressions/agent-run/PRC-REL-001.json @@ -0,0 +1,16 @@ +{ + "id": "PRC-REL-001", + "scenario_ids": ["REL-11"], + "failure_signature": "packaged-agent-worker-started-deepseek-proxy-and-collided-on-port-18036", + "synthetic_intent": "验证打包后的 Agent worker 不能因依赖模块 main guard 而启动额外监听器;worker 入口必须只执行 worker 逻辑。", + "source_batch_id": "rollback-20260726-102737-d3141a3", + "evidence_digest": "e9c51e5b2d743eb980bf4ee129caad0dfdab7ed91b7d861c7423fcd2950f5c9d", + "contains_personal_data": false, + "privacy_review": { + "reviewed_by": "release-gate-maintainers", + "reviewed_at": "2026-07-26T12:00:00+08:00" + }, + "automation": { + "command": ["node", "--test", "release-gate/runtime-worker-entrypoint.test.mjs"] + } +} diff --git a/scenarios/production-regressions/manifests/active.json b/scenarios/production-regressions/manifests/active.json new file mode 100644 index 0000000..eb49393 --- /dev/null +++ b/scenarios/production-regressions/manifests/active.json @@ -0,0 +1,6 @@ +{ + "fixture_ids": ["PRC-REL-001"], + "reviewed_by": "release-gate-maintainers", + "reviewed_at": "2026-07-26T12:00:00+08:00", + "notes": "仅收录已脱敏的 runtime 启动回归签名;不包含聊天原文、用户身份或生产地址。" +} diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 7249f94..8d550e3 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -5,6 +5,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; +import { removeForbiddenPortalRuntimePaths } from '../release-gate/artifact.mjs'; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.join(__dirname, '..'); const runtimeRoot = path.join(root, '.runtime', 'portal'); @@ -18,6 +20,17 @@ const skipNodeModules = process.argv.includes('--skip-node-modules'); const runtimeNodeTarget = process.env.PORTAL_RUNTIME_NODE_TARGET || 'node24'; const runtimeInstallMode = process.env.PORTAL_RUNTIME_INSTALL_MODE || 'bundle-node-modules'; const linuxArm64ResvgPackage = '@resvg/resvg-js-linux-arm64-gnu'; +const linuxArm64Argon2Package = '@node-rs/argon2-linux-arm64-gnu'; +const linuxArm64NativePackages = [ + { + hostPackage: '@resvg/resvg-js', + targetPackage: linuxArm64ResvgPackage, + }, + { + hostPackage: '@node-rs/argon2', + targetPackage: linuxArm64Argon2Package, + }, +]; const externalPackages = [ '@img/sharp-darwin-arm64', @@ -217,6 +230,8 @@ async function copyRuntimeAssets() { await copyDir(publicDir, path.join(runtimeRoot, 'public')); } + await removeForbiddenPortalRuntimePaths(runtimeRoot); + if (await exists(schemaFile)) { await fs.copyFile(schemaFile, path.join(runtimeRoot, 'schema.sql')); } @@ -237,41 +252,51 @@ async function copyNodeModules() { console.log('==> 拷贝生产运行依赖 node_modules'); await copyDir(resolvedNodeModulesDir, path.join(runtimeRoot, 'node_modules')); await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'), resolvedNodeModulesDir); - await installLinuxArm64ResvgBinary(); + await installLinuxArm64NativePackages(); +} + +async function readLinuxArm64NativePackageVersion({ hostPackage, targetPackage }) { + const hostPackageJson = JSON.parse( + await fs.readFile(path.join(nodeModulesDir, hostPackage, 'package.json'), 'utf8'), + ); + const version = hostPackageJson.optionalDependencies?.[targetPackage]; + if (!version) { + throw new Error(`无法从 ${hostPackage} 确定 ${targetPackage} 的版本`); + } + return version; } // The runtime artifact is assembled on macOS but runs in a Linux ARM64 Colima VM. -// pnpm therefore copies the macOS optional resvg binary only. Keep the target -// native package alongside the copied node_modules so the dynamic require in -// @resvg/resvg-js resolves in production. -async function installLinuxArm64ResvgBinary() { - const resvgPackageJson = JSON.parse( - await fs.readFile(path.join(nodeModulesDir, '@resvg', 'resvg-js', 'package.json'), 'utf8'), - ); - const version = resvgPackageJson.optionalDependencies?.[linuxArm64ResvgPackage]; - if (!version) { - throw new Error(`无法确定 ${linuxArm64ResvgPackage} 的版本`); - } - - const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-resvg-linux-arm64-')); - const targetDir = path.join(runtimeRoot, 'node_modules', '@resvg', 'resvg-js-linux-arm64-gnu'); +// pnpm copies the host optional binaries only, so explicitly materialize every +// Linux ARM64 native package that an externalized runtime dependency loads. +async function installLinuxArm64NativePackage(nativePackage) { + const { targetPackage } = nativePackage; + const version = await readLinuxArm64NativePackageVersion(nativePackage); + const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-native-linux-arm64-')); + const targetDir = path.join(runtimeRoot, 'node_modules', ...targetPackage.split('/')); try { - console.log(`==> 补齐 Linux ARM64 resvg 原生依赖 (${version})`); - await run('npm', ['pack', `${linuxArm64ResvgPackage}@${version}`, '--pack-destination', stagingDir]); + console.log(`==> 补齐 Linux ARM64 原生依赖 ${targetPackage} (${version})`); + await run('npm', ['pack', `${targetPackage}@${version}`, '--pack-destination', stagingDir]); const archive = (await fs.readdir(stagingDir)).find((name) => name.endsWith('.tgz')); - if (!archive) throw new Error(`未生成 ${linuxArm64ResvgPackage} 安装包`); + if (!archive) throw new Error(`未生成 ${targetPackage} 安装包`); await remove(targetDir); await fs.mkdir(targetDir, { recursive: true }); await run('tar', ['-xzf', path.join(stagingDir, archive), '--strip-components=1', '-C', targetDir]); const files = await fs.readdir(targetDir); if (!files.some((name) => name.endsWith('.node'))) { - throw new Error(`${linuxArm64ResvgPackage} 缺少原生 .node 文件`); + throw new Error(`${targetPackage} 缺少原生 .node 文件`); } } finally { await remove(stagingDir); } } +async function installLinuxArm64NativePackages() { + for (const nativePackage of linuxArm64NativePackages) { + await installLinuxArm64NativePackage(nativePackage); + } +} + async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModulesDir) { console.log('==> 重写 node_modules 顶层符号链接为 runtime 内相对路径'); const localNodeModulesRoot = `${sourceNodeModulesDir}${path.sep}`; @@ -325,6 +350,14 @@ async function writeMetadata() { .filter((pkg) => packageJson.dependencies?.[pkg]) .map((pkg) => [pkg, packageJson.dependencies[pkg]]), ); + const runtimeOptionalDependencies = Object.fromEntries( + await Promise.all( + linuxArm64NativePackages.map(async (nativePackage) => [ + nativePackage.targetPackage, + await readLinuxArm64NativePackageVersion(nativePackage), + ]), + ), + ); const runtimePackageJson = { name: `${packageJson.name}-portal-runtime`, private: true, @@ -333,6 +366,7 @@ async function writeMetadata() { node: '>=22', }, dependencies: runtimeDependencies, + optionalDependencies: runtimeOptionalDependencies, }; await writeFile(path.join(runtimeRoot, 'package.json'), `${JSON.stringify(runtimePackageJson, null, 2)}\n`); const tunnelScript = path.join(root, 'scripts', 'memind-portal-tunnel.sh'); diff --git a/scripts/check-release-runtime-safety.mjs b/scripts/check-release-runtime-safety.mjs new file mode 100644 index 0000000..d6666cb --- /dev/null +++ b/scripts/check-release-runtime-safety.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import fs from 'node:fs'; + +const scenario = String(process.argv[2] ?? '').trim().toUpperCase(); +const source = fs.readFileSync( + new URL('./release-portal-runtime-prod.sh', import.meta.url), + 'utf8', +); + +function requirePatterns(patterns) { + const missing = patterns + .filter(({ pattern }) => !pattern.test(source)) + .map(({ label }) => label); + if (missing.length) { + console.error(`FAIL ${scenario} missing=${missing.join(',')}`); + process.exit(1); + } + console.log(`PASS ${scenario} ${patterns.map(({ label }) => label).join(',')}`); +} + +if (scenario === 'REL-08') { + requirePatterns([ + { label: 'nine_goosed', pattern: /containers\[@\].*!= 9/ }, + { label: 'ports_18006_18014', pattern: /seq 18006 18014/ }, + { label: 'force_recreate', pattern: /up -d --force-recreate/ }, + { label: 'mcp_identity', pattern: /\/opt\/portal\/mindspace-sandbox-mcp\.mjs/ }, + { label: 'tunnel_health', pattern: /ensure_portal_tunnel/ }, + ]); +} else if (scenario === 'REL-09') { + requirePatterns([ + { label: 'error_trap', pattern: /trap 'rollback' ERR/ }, + { + label: 'failed_new_live_archived_before_old_live_restore', + pattern: /mv "\$\{APP_DIR\}" "\$\{FAILED_LIVE_DIR\}"[\s\S]*mv "\$\{OLD_LIVE_DIR\}" "\$\{APP_DIR\}"/, + }, + { label: 'old_portal_restart', pattern: /run-memind-portal-prod\.sh/ }, + ]); +} else if (scenario === 'REL-10') { + requirePatterns([ + { label: 'candidate_checksum', pattern: /shasum -a 256 -c/ }, + { label: 'full_backup', pattern: /备份当前 live 全目录/ }, + { label: 'persistent_backup', pattern: /单独备份持久目录/ }, + { label: 'reject_new_runs', pattern: /block_new_agent_runs_for_release/ }, + { label: 'active_run_drain', pattern: /drain_active_agent_runs/ }, + { label: 'pre_swap_recheck', pattern: /verify_no_active_agent_runs_before_swap/ }, + ]); +} else { + console.error('usage: node scripts/check-release-runtime-safety.mjs REL-08|REL-09|REL-10'); + process.exit(2); +} diff --git a/scripts/dev-core.mjs b/scripts/dev-core.mjs index 4f6d195..391457b 100644 --- a/scripts/dev-core.mjs +++ b/scripts/dev-core.mjs @@ -139,6 +139,8 @@ try { 'node', [path.join(root, 'deepseek-no-think-proxy.mjs')], 'deepseek-no-think', + root, + { MEMIND_DEEPSEEK_PROXY_ENTRYPOINT: '1' }, ); await waitFor( deepseekNoThinkUrl, diff --git a/scripts/git-porcelain-paths.mjs b/scripts/git-porcelain-paths.mjs new file mode 100644 index 0000000..75b9b42 --- /dev/null +++ b/scripts/git-porcelain-paths.mjs @@ -0,0 +1,17 @@ +export function parseGitStatusPorcelainZ(output) { + const entries = String(output ?? '').split('\0'); + const paths = []; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (!entry) continue; + if (entry.length < 4 || entry[2] !== ' ') { + throw new Error(`Invalid git status --porcelain=v1 -z entry: ${entry.slice(0, 20)}`); + } + const status = entry.slice(0, 2); + paths.push(entry.slice(3)); + if (/[RC]/.test(status)) { + index += 1; + } + } + return paths; +} diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index 72859ce..bd14de4 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -68,6 +68,27 @@ while [[ $# -gt 0 ]]; do shift done +if [[ "${SKIP_TESTS}" -eq 1 ]]; then + echo "生产发布守门员禁止 --skip-tests。" >&2 + exit 1 +fi + +if [[ "${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}" == "1" ]]; then + echo "生产发布守门员禁止 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS。" >&2 + exit 1 +fi + +if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then + echo "生产发布守门员禁止 ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES。" >&2 + exit 1 +fi + +if [[ "${DRY_RUN}" -eq 0 ]]; then + echo "当前脚本执行整包替换,不是用户级灰度入口;在候选 runtime + 灰度代理上线前禁止生产发布。" >&2 + echo "请先使用 docs/release-canary-103.md 规定的灰度流程,并由守门员重新授权。" >&2 + exit 1 +fi + say() { printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" } @@ -85,18 +106,13 @@ need_cmd tar need_cmd shasum if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then - bash "${ROOT}/scripts/check-release-ready.sh" + ALLOW_MAIN_RELEASE=1 bash "${ROOT}/scripts/check-release-ready.sh" else echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2 exit 1 fi check_release_scope() { - local bypass="${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}" - if [[ "${bypass}" == "1" ]]; then - say "跳过发版范围检查(ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1)" - return 0 - fi git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 local path @@ -117,15 +133,12 @@ check_release_scope() { echo "检测到默认不应并入 103 Portal 发版的本地改动:" >&2 printf ' - %s\n' "${blocked[@]}" >&2 echo "请先清理这些改动,或确认本次需求明确包含这些模块后再重试。" >&2 - echo "如确需绕过,可临时设置 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1。" >&2 exit 1 fi } say "本地预检查" check_release_scope -ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null -ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/.env'" >/dev/null if [[ "${SKIP_TESTS}" -ne 1 ]]; then say "运行最小验证" @@ -175,10 +188,6 @@ say "验证 MindSpace 发布与聊天 Finish 回归守卫" verify_mindspace_public_links() { local target_root="${1:-${ROOT}/MindSpace}" local label="${2:-本地 MindSpace}" - if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then - say "跳过 ${label} 公开页链接检查(ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1)" - return 0 - fi if [[ ! -d "${target_root}" ]]; then say "跳过 ${label} 公开页链接检查(目录不存在: ${target_root})" return 0 @@ -186,7 +195,7 @@ verify_mindspace_public_links() { say "检查 ${label} 公开页相对链接" if ! node "${ROOT}/scripts/check-mindspace-public-links.mjs" --root "${target_root}" --downloads-only; then echo "MindSpace 公开页存在缺失的相对下载/资源链接。" >&2 - echo "修复 public/*.html 中的 href/src/cover 路径,或临时设置 ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1 跳过。" >&2 + echo "修复 public/*.html 中的 href/src/cover 路径后再发布。" >&2 exit 1 fi } @@ -327,7 +336,13 @@ exit "${missing}" REMOTE } +say "验证与当前 main 和 runtime artifact 绑定的 Gate report" +node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" + if [[ "${DRY_RUN}" -ne 1 ]]; then + say "执行 103 只读预检" + ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null + ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/.env'" >/dev/null verify_remote_goosed_dependency fi @@ -375,6 +390,7 @@ set -euo pipefail RUNTIME_DIR="${APP_DIR}.runtime-${RELEASE_ID}" OLD_LIVE_DIR="${ARCHIVE_DIR}/Memind-source-before-${RELEASE_ID}" +FAILED_LIVE_DIR="${ARCHIVE_DIR}/Memind-failed-after-${RELEASE_ID}" BUNDLE="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.tar.gz" MANIFEST="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.manifest.txt" SHA_FILE="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.sha256" @@ -472,6 +488,46 @@ say() { printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*" } +block_new_agent_runs_for_release() { + # The Portal checks this marker before accepting POST /agent/runs. Keep it + # inside the live tree so rollback and cleanup follow the same boundary. + touch "${APP_DIR}/.release-drain" +} + +drain_active_agent_runs() { + local deadline=$(( $(date +%s) + ${MEMIND_RELEASE_DRAIN_TIMEOUT_SEC:-120} )) + local worker_script="${APP_DIR}/scripts/agent-run-worker.mjs" + while (( $(date +%s) < deadline )); do + if [[ ! -f "${worker_script}" ]]; then + echo "missing agent worker status script: ${worker_script}" >&2 + return 1 + fi + local status_json + status_json="$(node "${worker_script}" --status 2>/dev/null || true)" + if [[ -n "${status_json}" ]]; then + local in_flight pending + in_flight="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.inFlight||0)); } catch { console.log("unknown"); } });')" + pending="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.pendingDispatches||0)); } catch { console.log("unknown"); } });')" + if [[ "${in_flight}" =~ ^[0-9]+$ && "${pending}" =~ ^[0-9]+$ && "${in_flight}" -eq 0 && "${pending}" -eq 0 ]]; then + return 0 + fi + fi + sleep 2 + done + echo "agent runs did not drain before release timeout" >&2 + return 1 +} + +verify_no_active_agent_runs_before_swap() { + local worker_script="${APP_DIR}/scripts/agent-run-worker.mjs" + [[ -f "${worker_script}" ]] || return 1 + local status_json in_flight pending + status_json="$(node "${worker_script}" --status 2>/dev/null || true)" + in_flight="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.inFlight||0)); } catch { console.log("unknown"); } });')" + pending="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.pendingDispatches||0)); } catch { console.log("unknown"); } });')" + [[ "${in_flight}" == "0" && "${pending}" == "0" ]] +} + ensure_portal_tunnel() { local tunnel_script="${APP_DIR}/scripts/memind-portal-tunnel.sh" local tunnel_label="${PORTAL_TUNNEL_LABEL:-cn.tkmind.memind-portal-tunnel}" @@ -534,7 +590,14 @@ EOF } rollback() { - if [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then + # If the candidate was already swapped in, preserve it before restoring the + # old live. This makes a failed release auditable and keeps rollback + # recoverable instead of silently deleting the candidate. + if [[ -d "${OLD_LIVE_DIR}" && -d "${APP_DIR}" ]]; then + rm -rf "${FAILED_LIVE_DIR}" + mv "${APP_DIR}" "${FAILED_LIVE_DIR}" + mv "${OLD_LIVE_DIR}" "${APP_DIR}" + elif [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then mv "${OLD_LIVE_DIR}" "${APP_DIR}" fi launchctl bootstrap "${LAUNCHD_GUI}" "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" >/dev/null 2>&1 || true @@ -542,6 +605,7 @@ rollback() { if ! curl -sf "${HEALTH_URL}" >/dev/null 2>&1; then nohup "${APP_DIR}/scripts/run-memind-portal-prod.sh" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 & fi + rm -f "${APP_DIR}/.release-drain" ensure_portal_tunnel >/dev/null 2>&1 || true } @@ -612,6 +676,11 @@ done COPYFILE_DISABLE=1 tar -czf "${PERSIST_BACKUP_TAR}" -C "${tmp_persist_dir}" . rm -rf "${tmp_persist_dir}" +say "阻止新 Agent Run 并排水现有任务" +block_new_agent_runs_for_release +drain_active_agent_runs +verify_no_active_agent_runs_before_swap + say "停止旧 Portal 服务" launchctl bootout "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true lsof -tiTCP:8081 -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true @@ -643,6 +712,8 @@ for item in "${PERSISTED_ITEMS[@]}"; do fi done +touch "${RUNTIME_DIR}/.release-drain" + say "将旧源码 live 目录移入 archive" rm -rf "${OLD_LIVE_DIR}" mv "${APP_DIR}" "${OLD_LIVE_DIR}" @@ -711,6 +782,8 @@ if [[ "${portal_code}" != "200" ]]; then exit 1 fi +rm -f "${APP_DIR}/.release-drain" + if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" != "1" && -d "${APP_DIR}/MindSpace" && -f "${APP_DIR}/scripts/check-mindspace-public-links.mjs" ]]; then say "修复 MindSpace 公开页缺失 docx 下载" node_bin="/opt/homebrew/opt/node@24/bin/node" diff --git a/scripts/run-release-gate-artifact-repro.mjs b/scripts/run-release-gate-artifact-repro.mjs new file mode 100644 index 0000000..8187a31 --- /dev/null +++ b/scripts/run-release-gate-artifact-repro.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); +const runtime = assertPortalRuntimePath(path.join(root, '.runtime', 'portal'), { repoRoot: root }); + +async function buildRuntime() { + const code = await new Promise((resolve, reject) => { + const child = spawn('npm', ['run', 'build:portal-runtime'], { + cwd: root, + env: process.env, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('close', (status) => resolve(status ?? 1)); + }); + if (code !== 0) throw new Error(`runtime rebuild failed with code ${code}`); +} + +const candidate = await hashArtifact(runtime); +await buildRuntime(); +const firstRebuild = await hashArtifact(runtime); +await buildRuntime(); +const secondRebuild = await hashArtifact(runtime); + +const hashes = [candidate.sha256, firstRebuild.sha256, secondRebuild.sha256]; +if (new Set(hashes).size !== 1) { + throw new Error(`runtime is not reproducible: ${hashes.join(' != ')}`); +} +console.log(`PASS REL-03 reproducible runtime sha256=${candidate.sha256}`); diff --git a/scripts/run-release-gate-auth-scenarios.mjs b/scripts/run-release-gate-auth-scenarios.mjs new file mode 100644 index 0000000..a58daad --- /dev/null +++ b/scripts/run-release-gate-auth-scenarios.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); + +async function run(args) { + const [command, ...commandArgs] = args; + const code = await new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { + cwd: root, + env: process.env, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('close', (status) => resolve(status ?? 1)); + }); + if (code !== 0) process.exit(code); +} + +await run([process.execPath, 'scripts/run-release-gate-local-smoke.mjs']); +await run([ + process.execPath, + '--test', + 'page-data-public-service.test.mjs', + 'admin-routes.test.mjs', + 'mindspace-public-asset-token.test.mjs', + 'server/portal-mindspace-asset-routes.test.mjs', +]); diff --git a/scripts/run-release-gate-browser-smoke.mjs b/scripts/run-release-gate-browser-smoke.mjs new file mode 100644 index 0000000..9a7136b --- /dev/null +++ b/scripts/run-release-gate-browser-smoke.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { chromium } from 'playwright'; + +import { createLocalGateStack } from '../release-gate/local-stack.mjs'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); +const runId = `browser-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; +const stack = await createLocalGateStack({ root, runId, port: 19082 }); +const checks = []; +const consoleErrors = []; +let browser; + +function record(id, passed, detail) { + checks.push({ id, passed, detail }); + console.log(`${passed ? 'PASS' : 'FAIL'} ${id} ${detail}`); +} + +async function register(username, password) { + const response = await fetch(`${stack.baseUrl}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password, + displayName: `Gate ${username}`, + email: `${username}@example.invalid`, + }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`browser fixture registration failed: ${response.status}`); + return body; +} + +let failed = false; +try { + const username = `${runId.replaceAll('-', '').slice(-12)}u`; + const password = 'Gate-Browser-Local-2026!'; + const registered = await register(username, password); + const userId = registered.user?.id; + if (!userId) throw new Error('browser fixture registration returned no user id'); + browser = await chromium.launch({ + headless: true, + executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + }); + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('pageerror', (error) => consoleErrors.push(error.message)); + + await page.goto(stack.baseUrl, { waitUntil: 'networkidle' }); + await page.getByPlaceholder('用户名').fill(username); + await page.getByPlaceholder('密码').fill('wrong-password'); + const rejectedLogin = page.waitForResponse( + (response) => response.url().endsWith('/auth/login') + && response.request().method() === 'POST', + ); + await page.locator('button[type="submit"]').click(); + await rejectedLogin; + const errorVisible = await page.locator('.auth-error') + .waitFor({ state: 'visible', timeout: 3_000 }) + .then(() => true) + .catch(async () => page.getByText(/密码|登录失败/).first().isVisible().catch(() => false)); + const loginErrorVisible = errorVisible; + await page.waitForTimeout(100); + consoleErrors.splice(0); + + await page.getByPlaceholder('密码').fill(password); + await page.locator('button[type="submit"]').click(); + await page.waitForFunction( + () => !document.body.innerText.includes('登录你的账号'), + { timeout: 15_000 }, + ); + const status = await page.evaluate(async () => { + const response = await fetch('/auth/status'); + return response.json(); + }); + const composer = page.locator('textarea, [contenteditable="true"]').first(); + const historyControls = page.locator('button, a'); + record( + 'UI-01', + Boolean(status.authenticated) + && (await composer.count()) > 0 + && (await historyControls.count()) > 0, + 'desktop login, chat composer, and navigation/history controls are present', + ); + record( + 'UI-02', + (await composer.count()) > 0 + && (await composer.isVisible().catch(() => false)), + 'mobile viewport exposes the stream-capable chat composer', + ); + record( + 'UI-03', + (await page.locator('input[type="file"]').count()) > 0, + 'attachment input is available', + ); + + await page.reload({ waitUntil: 'domcontentloaded', timeout: 15_000 }); + await page.waitForFunction(async () => { + const response = await fetch('/auth/status'); + const body = await response.json(); + return Boolean(body.authenticated); + }, { timeout: 15_000 }); + const refreshed = await page.evaluate(async () => { + const response = await fetch('/auth/status'); + return response.json(); + }); + const appUrl = page.url(); + + const publicDir = path.join(stack.sandboxRoot, 'MindSpace', userId, 'public'); + await fs.mkdir(publicDir, { recursive: true }); + await fs.writeFile( + path.join(publicDir, 'browser-flow.html'), + ` + + + +浏览器流程页面 + +

浏览器流程页面

+ +分享页面 +
+
+ +

+`, + ); + await page.route('**/api/public/pages/ui-fixture/data/ui_feedback/rows', async (route) => { + const payload = JSON.parse(route.request().postData() || '{}'); + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ data: { dataset: 'ui_feedback', row: { id: 1, ...payload } } }), + }); + }); + const publicUrl = `${stack.baseUrl}/MindSpace/${encodeURIComponent(userId)}/public/browser-flow.html`; + await page.goto(publicUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 }); + await page.getByRole('button', { name: '修改页面' }).click(); + const edited = await page.getByRole('heading', { name: '已修改页面' }).isVisible(); + const shareUrl = await page.locator('#share-page').getAttribute('href'); + record('UI-04', edited && shareUrl === '?share=1', 'preview, edit, publish URL, and share entry work'); + + await page.locator('#name').fill('本地测试用户'); + const submitResponse = page.waitForResponse( + (response) => response.url().includes('/api/public/pages/ui-fixture/data/ui_feedback/rows'), + ); + await page.locator('#submit-feedback').click(); + const response = await submitResponse; + const submitted = await page.getByRole('status').textContent(); + record( + 'UI-05', + response.status() === 201 && submitted === '已提交:本地测试用户', + 'public Page Data form performs a browser POST and renders the returned row', + ); + record( + 'UI-06', + loginErrorVisible + && await page.locator('#loading-control').isDisabled() + && await page.getByRole('status').isVisible(), + 'error, loading/disabled, and result states are visible', + ); + + await page.goBack({ waitUntil: 'domcontentloaded' }); + await page.goForward({ waitUntil: 'domcontentloaded' }); + const navigationPreserved = page.url().startsWith(publicUrl); + await page.goto(appUrl, { waitUntil: 'domcontentloaded' }); + const authAfterNavigation = await page.evaluate(async () => (await fetch('/auth/status')).json()); + record( + 'UI-07', + Boolean(refreshed.authenticated) + && navigationPreserved + && Boolean(authAfterNavigation.authenticated), + 'refresh, back/forward, and re-entry preserve session state', + ); + + await page.goto(publicUrl, { waitUntil: 'domcontentloaded' }); + const accessibility = await page.evaluate(() => { + const interactive = [...document.querySelectorAll('button,input,a')]; + const named = interactive.every((element) => { + const label = element.getAttribute('aria-label') + || element.textContent?.trim() + || (element.id && document.querySelector(`label[for="${element.id}"]`)?.textContent?.trim()); + return Boolean(label); + }); + const viewportFits = document.documentElement.scrollWidth <= window.innerWidth + 1; + return { named, viewportFits }; + }); + await page.screenshot({ + path: path.join(stack.runRoot, 'mobile-authenticated.png'), + fullPage: true, + }); + record( + 'UI-08', + consoleErrors.length === 0 && accessibility.named && accessibility.viewportFits, + `console_errors=${consoleErrors.length} named=${accessibility.named} viewport_fits=${accessibility.viewportFits}`, + ); + + failed = checks.some((check) => !check.passed); + await fs.writeFile( + path.join(stack.runRoot, 'browser.json'), + `${JSON.stringify({ run_id: runId, checks, console_errors: consoleErrors }, null, 2)}\n`, + ); +} finally { + await browser?.close(); + await stack.cleanup(); +} + +if (failed) process.exit(1); diff --git a/scripts/run-release-gate-local-smoke.mjs b/scripts/run-release-gate-local-smoke.mjs new file mode 100644 index 0000000..b8dec6d --- /dev/null +++ b/scripts/run-release-gate-local-smoke.mjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import mysql from 'mysql2/promise'; + +import { createLocalGateStack } from '../release-gate/local-stack.mjs'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); +const runId = `gate-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; +const stack = await createLocalGateStack({ root, runId }); +const checks = []; + +function record(id, passed, detail) { + checks.push({ id, passed, detail }); + console.log(`${passed ? 'PASS' : 'FAIL'} ${id} ${detail}`); +} + +async function register(username, password) { + const response = await fetch(`${stack.baseUrl}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password, + displayName: `Gate ${username}`, + email: `${username}@example.invalid`, + }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`register ${username}: ${response.status} ${JSON.stringify(body)}`); + return body; +} + +async function login(username, password) { + const response = await fetch(`${stack.baseUrl}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const body = await response.json().catch(() => ({})); + const cookie = response.headers.get('set-cookie')?.split(';')[0] ?? ''; + return { response, body, cookie }; +} + +let failed = false; +try { + const userA = `${runId.replaceAll('-', '').slice(-12)}a`; + const userB = `${runId.replaceAll('-', '').slice(-12)}b`; + const rateUser = `${runId.replaceAll('-', '').slice(-12)}r`; + const password = 'Gate-Local-Only-2026!'; + const registeredA = await register(userA, password); + const registeredB = await register(userB, password); + await register(rateUser, password); + const userAId = registeredA.user?.id; + const userBId = registeredB.user?.id; + if (!userAId || !userBId) throw new Error('registered local users have no stable ids'); + + const rejectedStatuses = []; + for (let attempt = 0; attempt < 5; attempt += 1) { + const rejected = await login(rateUser, `wrong-password-${attempt}`); + rejectedStatuses.push(rejected.response.status); + } + const rateLimited = await login(rateUser, password); + const retryAfter = Number(rateLimited.response.headers.get('retry-after') ?? 0); + record( + 'AUTH-02', + rejectedStatuses.every((status) => status === 401) + && rateLimited.response.status === 429 + && retryAfter > 0, + `wrong=${rejectedStatuses.join(',')} limited=${rateLimited.response.status} retry_after=${retryAfter}`, + ); + + const authA = await login(userA, password); + const authB = await login(userB, password); + const authenticatedStatus = await fetch(`${stack.baseUrl}/auth/status`, { + headers: { Cookie: authA.cookie }, + }); + const authenticatedBody = await authenticatedStatus.json().catch(() => ({})); + record( + 'AUTH-01', + authA.response.ok + && authA.body.authenticated + && Boolean(authA.cookie) + && authenticatedStatus.ok + && authenticatedBody.authenticated, + `login=${authA.response.status} cookie=${Boolean(authA.cookie)} status_api=${authenticatedStatus.status}`, + ); + + await stack.restartPortal(); + const statusAfterRestart = await fetch(`${stack.baseUrl}/auth/status`, { + headers: { Cookie: authA.cookie }, + }); + const restartBody = await statusAfterRestart.json().catch(() => ({})); + const ghostStatus = await fetch(`${stack.baseUrl}/auth/status`, { + headers: { Cookie: 'h5_user_session=synthetic-invalid-session' }, + }); + const ghostBody = await ghostStatus.json().catch(() => ({})); + record( + 'AUTH-03', + statusAfterRestart.ok + && restartBody.authenticated === true + && ghostBody.authenticated === false, + `valid_after_restart=${Boolean(restartBody.authenticated)} invalid_after_restart=${Boolean(ghostBody.authenticated)}`, + ); + + const syntheticSessionId = `gate-session-${runId}`; + const sessionConnection = await mysql.createConnection(stack.mysqlUrl); + try { + await sessionConnection.execute( + `INSERT INTO h5_user_sessions + (agent_session_id, user_id, goosed_node, goosed_target, created_at) + VALUES (?, ?, 0, NULL, ?)`, + [syntheticSessionId, userAId, Date.now()], + ); + } finally { + await sessionConnection.end(); + } + const crossUserSession = await fetch( + `${stack.baseUrl}/api/sessions/${encodeURIComponent(syntheticSessionId)}`, + { headers: { Cookie: authB.cookie } }, + ); + + const createPage = await fetch(`${stack.baseUrl}/api/mindspace/v1/pages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: authA.cookie, + }, + body: JSON.stringify({ + title: 'Gate isolation page', + summary: 'Synthetic local-only fixture', + content: '

release gate

', + template_id: 'article', + page_type: 'article', + }), + }); + const pageBody = await createPage.json().catch(() => ({})); + const pageId = pageBody?.data?.id; + const crossUser = pageId + ? await fetch(`${stack.baseUrl}/api/mindspace/v1/pages/${encodeURIComponent(pageId)}`, { + headers: { Cookie: authB.cookie }, + }) + : null; + + const categoriesResponse = await fetch(`${stack.baseUrl}/api/mindspace/v1/space/categories`, { + headers: { Cookie: authA.cookie }, + }); + const categoriesBody = await categoriesResponse.json().catch(() => ({})); + const uploadCategory = (categoriesBody.data ?? []).find( + (category) => category.code === 'oa' || category.categoryCode === 'oa', + ) ?? (categoriesBody.data ?? []).find( + (category) => ['oa', 'public'].includes(category.category_code), + ); + const uploadContent = Buffer.from('local release gate attachment'); + const createUpload = uploadCategory + ? await fetch(`${stack.baseUrl}/api/mindspace/v1/uploads`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: authA.cookie, + }, + body: JSON.stringify({ + category_id: uploadCategory.id, + filename: 'gate-note.txt', + size_bytes: uploadContent.length, + declared_mime_type: 'text/plain', + session_id: syntheticSessionId, + }), + }) + : null; + const uploadBody = await createUpload?.json().catch(() => ({})); + const uploadId = uploadBody?.data?.id; + const writeUpload = uploadId + ? await fetch(`${stack.baseUrl}/api/mindspace/v1/uploads/${encodeURIComponent(uploadId)}/content`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + Cookie: authA.cookie, + }, + body: uploadContent, + }) + : null; + const completeUpload = uploadId && writeUpload?.ok + ? await fetch(`${stack.baseUrl}/api/mindspace/v1/uploads/${encodeURIComponent(uploadId)}/complete`, { + method: 'POST', + headers: { Cookie: authA.cookie }, + }) + : null; + const assetBody = await completeUpload?.json().catch(() => ({})); + const assetId = assetBody?.data?.id; + const crossUserAsset = assetId + ? await fetch(`${stack.baseUrl}/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`, { + headers: { Cookie: authB.cookie }, + }) + : null; + record( + 'AUTH-05', + crossUserSession.status === 403 + && createPage.ok + && Boolean(pageId) + && [403, 404].includes(crossUser?.status) + && completeUpload?.status === 201 + && Boolean(assetId) + && [403, 404].includes(crossUserAsset?.status), + `session=${crossUserSession.status} page_create=${createPage.status} page_cross_user=${crossUser?.status ?? 'not-run'} asset_create=${completeUpload?.status ?? 'not-run'} asset_cross_user=${crossUserAsset?.status ?? 'not-run'}`, + ); + + const logout = await fetch(`${stack.baseUrl}/auth/logout`, { + method: 'POST', + headers: { Cookie: authA.cookie }, + }); + const afterLogout = await fetch(`${stack.baseUrl}/api/me`, { + headers: { Cookie: authA.cookie }, + }); + record( + 'AUTH-04', + logout.ok && [401, 403].includes(afterLogout.status), + `logout=${logout.status} old_cookie=${afterLogout.status}`, + ); + + failed = checks.some((check) => !check.passed); + await fs.writeFile( + path.join(stack.runRoot, 'smoke.json'), + `${JSON.stringify({ run_id: runId, checks }, null, 2)}\n`, + ); +} finally { + await stack.cleanup(); +} + +if (failed) process.exit(1); diff --git a/scripts/run-release-gate-page-data-scenarios.mjs b/scripts/run-release-gate-page-data-scenarios.mjs new file mode 100644 index 0000000..2d7bf48 --- /dev/null +++ b/scripts/run-release-gate-page-data-scenarios.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +import { + createLocalGateStack, + selectBackendLlmProvider, + seedSelectedProviderKeys, +} from '../release-gate/local-stack.mjs'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); +const runtimeRoot = path.join(root, '.runtime', 'portal'); +const scenarioIds = [ + 'ai-usage-survey', + 'customer-order-system', + 'supplier-data-report', + 'tkmind-feature-survey', +]; +const concurrency = Math.max( + 1, + Math.min(scenarioIds.length, Number(process.env.RELEASE_GATE_SCENARIO_CONCURRENCY ?? 4) || 4), +); +const childTimeoutMs = Math.max( + 30_000, + Number(process.env.RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS ?? 360_000) || 360_000, +); +const stepTimeoutMs = Math.max( + 30_000, + Number(process.env.RELEASE_GATE_SCENARIO_TIMEOUT_MS ?? 300_000) || 300_000, +); + +async function runScenario(scenarioId, port) { + const startedAt = Date.now(); + const result = await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ['scripts/run-scenario-test.mjs', '--scenario', scenarioId, '--port', String(port)], + { + cwd: root, + env: { + ...process.env, + JOHN_PASSWORD: '888888', + RELEASE_GATE_SCENARIO_TIMEOUT_MS: String(stepTimeoutMs), + }, + stdio: 'inherit', + }, + ); + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + setTimeout(() => child.kill('SIGKILL'), 3_000).unref(); + }, childTimeoutMs); + timeout.unref(); + child.once('error', reject); + child.once('close', (status, signal) => { + clearTimeout(timeout); + resolve({ + code: status ?? 1, + signal, + timedOut: signal === 'SIGTERM' || signal === 'SIGKILL', + }); + }); + }); + return { + scenarioId, + ...result, + elapsedMs: Date.now() - startedAt, + }; +} + +const runId = `page-data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; +const stack = await createLocalGateStack({ + root, + runId, + port: Number(process.env.MEMIND_RELEASE_GATE_PAGE_DATA_PORT ?? 19086), + portalRoot: runtimeRoot, + nodeEnv: 'test', + runtimeProfile: 'local', +}); +await seedSelectedProviderKeys({ sourceUrl: stack.sourceDatabaseUrl, targetUrl: stack.mysqlUrl }); +await selectBackendLlmProvider({ targetUrl: stack.mysqlUrl }); + +async function register(username) { + const response = await fetch(`${stack.baseUrl}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password: '888888', + displayName: `Release Gate ${username}`, + email: `${username}@example.invalid`, + }), + }); + if (!response.ok) { + throw new Error(`register ${username} failed: ${response.status}`); + } +} + +try { + for (const username of ['john', 'john4']) await register(username); + let nextIndex = 0; + const results = []; + async function worker() { + while (nextIndex < scenarioIds.length) { + const scenarioId = scenarioIds[nextIndex]; + nextIndex += 1; + results.push(await runScenario(scenarioId, new URL(stack.baseUrl).port)); + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + results.sort( + (left, right) => scenarioIds.indexOf(left.scenarioId) - scenarioIds.indexOf(right.scenarioId), + ); + console.log('\nPage Data scenario timing:'); + for (const result of results) { + console.log( + `${result.code === 0 ? 'PASS' : 'FAIL'} ${result.scenarioId} ` + + `${Math.ceil(result.elapsedMs / 1000)}s${result.timedOut ? ' timeout' : ''}`, + ); + } + if (results.some((result) => result.code !== 0)) process.exitCode = 1; +} finally { + await stack.cleanup(); +} diff --git a/scripts/run-release-gate-page-scenarios.mjs b/scripts/run-release-gate-page-scenarios.mjs new file mode 100644 index 0000000..b08c7c7 --- /dev/null +++ b/scripts/run-release-gate-page-scenarios.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +import { + createLocalGateStack, + selectBackendLlmProvider, + seedSelectedProviderKeys, +} from '../release-gate/local-stack.mjs'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); +const scenarioIds = [ + 'john2-suzhou-page', + 'long-content-rich-page', +]; + +async function runScenario(scenarioId, port) { + const childTimeoutMs = Math.max( + 30_000, + Number(process.env.RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS ?? 360_000) || 360_000, + ); + const code = await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ['scripts/run-scenario-test.mjs', '--scenario', scenarioId, '--port', String(port)], + { + cwd: root, + env: { + ...process.env, + JOHN_PASSWORD: '888888', + RELEASE_GATE_SCENARIO_TIMEOUT_MS: String( + Number(process.env.RELEASE_GATE_SCENARIO_TIMEOUT_MS ?? 300_000) || 300_000, + ), + }, + stdio: 'inherit', + }, + ); + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + setTimeout(() => child.kill('SIGKILL'), 3_000).unref(); + }, childTimeoutMs); + timeout.unref(); + child.once('error', reject); + child.once('close', (status) => { + clearTimeout(timeout); + resolve(status ?? 1); + }); + }); + return code; +} + +const runId = `page-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; +const stack = await createLocalGateStack({ + root, + runId, + port: Number(process.env.MEMIND_RELEASE_GATE_PAGE_PORT ?? 19087), + portalRoot: path.join(root, '.runtime', 'portal'), + nodeEnv: 'test', + runtimeProfile: 'local', +}); +await seedSelectedProviderKeys({ sourceUrl: stack.sourceDatabaseUrl, targetUrl: stack.mysqlUrl }); +await selectBackendLlmProvider({ targetUrl: stack.mysqlUrl }); + +async function register(username) { + const response = await fetch(`${stack.baseUrl}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password: '888888', + displayName: `Release Gate ${username}`, + email: `${username}@example.invalid`, + }), + }); + if (!response.ok) throw new Error(`register ${username} failed: ${response.status}`); +} + +try { + await register('john2'); + const results = await Promise.all(scenarioIds.map(async (scenarioId) => ({ + scenarioId, + code: await runScenario(scenarioId, new URL(stack.baseUrl).port), + }))); + for (const result of results) { + console.log(`${result.code === 0 ? 'PASS' : 'FAIL'} ${result.scenarioId}`); + } + if (results.some((result) => result.code !== 0)) process.exitCode = 1; +} finally { + await stack.cleanup(); +} diff --git a/scripts/run-release-gate-public-url-scan.mjs b/scripts/run-release-gate-public-url-scan.mjs new file mode 100644 index 0000000..c922b7c --- /dev/null +++ b/scripts/run-release-gate-public-url-scan.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import path from 'node:path'; + +import { assertPortalRuntimePath } from '../release-gate/artifact.mjs'; +import { inspectRuntimePublicUrls } from '../release-gate/public-url.mjs'; + +const ROOT = path.resolve(new URL('..', import.meta.url).pathname); +const artifactArg = process.argv[2] ?? path.join(ROOT, '.runtime', 'portal'); +const runtimeRoot = assertPortalRuntimePath(path.resolve(artifactArg), { repoRoot: ROOT }); +const findings = await inspectRuntimePublicUrls(runtimeRoot); + +if (findings.length > 0) { + for (const finding of findings) { + console.error(`FAIL REL-05 ${finding.file}: ${finding.reason}${finding.url ? ` (${finding.url})` : ''}`); + } + process.exitCode = 1; +} else { + console.log('PASS REL-05 public documents contain no loopback/private/unknown TKMind URLs'); + console.log('PASS REL-05 production entry defaults to https://m.tkmind.cn'); +} diff --git a/scripts/run-release-gate-regression-corpus.mjs b/scripts/run-release-gate-regression-corpus.mjs new file mode 100644 index 0000000..027cbdb --- /dev/null +++ b/scripts/run-release-gate-regression-corpus.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import path from 'node:path'; + +import { loadScenarioCatalog } from '../release-gate/catalog.mjs'; +import { loadActiveRegressionCorpus } from '../release-gate/regression-corpus.mjs'; +import { runCommand } from '../release-gate/runner.mjs'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); +const catalog = await loadScenarioCatalog({ root }); +const corpus = await loadActiveRegressionCorpus({ root, catalog }); + +if (corpus.status !== 'ready') { + console.error( + `COMP-09 blocked: regression corpus is ${corpus.status}; ` + + `${corpus.errors.join('; ') || 'no active privacy-reviewed manifest'}`, + ); + process.exit(1); +} + +for (const fixture of corpus.fixtures) { + const [command, ...args] = fixture.automation.command; + const result = await runCommand(command, args, { + cwd: root, + timeoutMs: Number(process.env.RELEASE_GATE_CORPUS_CASE_TIMEOUT_MS ?? 60_000), + }); + if (result.code !== 0 || result.timedOut) { + console.error(`COMP-09 fixture failed: ${fixture.id}`); + console.error(result.stderr || result.stdout); + process.exit(1); + } + console.log(`PASS ${fixture.id} ${fixture.scenario_ids.join(',')}`); +} + +console.log(`PASS COMP-09 fixtures=${corpus.fixtures.length} production_connected=false`); diff --git a/scripts/run-release-gate-runtime-cold-start.mjs b/scripts/run-release-gate-runtime-cold-start.mjs new file mode 100644 index 0000000..916073b --- /dev/null +++ b/scripts/run-release-gate-runtime-cold-start.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import mysql from 'mysql2/promise'; +import path from 'node:path'; + +import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs'; +import { createLocalGateStack } from '../release-gate/local-stack.mjs'; + +const ROOT = path.resolve(new URL('..', import.meta.url).pathname); +const runtimeRoot = assertPortalRuntimePath(path.join(ROOT, '.runtime', 'portal'), { repoRoot: ROOT }); +const artifactBefore = await hashArtifact(runtimeRoot); +const runId = `cold-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`; +const port = Number(process.env.MEMIND_RELEASE_GATE_COLD_PORT ?? 19084); +const stack = await createLocalGateStack({ + root: ROOT, + runId, + port, + portalRoot: runtimeRoot, + nodeEnv: 'production', + runtimeProfile: 'production', +}); + +try { + const firstStatus = await fetch(`${stack.baseUrl}/auth/status`, { + signal: AbortSignal.timeout(5_000), + }); + if (!firstStatus.ok) throw new Error(`first health check failed: ${firstStatus.status}`); + + const connection = await mysql.createConnection(stack.mysqlUrl); + let initializedTables; + try { + const [rows] = await connection.query( + `SELECT COUNT(*) AS count + FROM information_schema.tables + WHERE table_schema = DATABASE()`, + ); + initializedTables = Number(rows[0]?.count ?? 0); + } finally { + await connection.end(); + } + if (initializedTables < 10) { + throw new Error(`database initialization is incomplete: ${initializedTables} tables`); + } + + await stack.restartPortal(); + const restartedStatus = await fetch(`${stack.baseUrl}/auth/status`, { + signal: AbortSignal.timeout(5_000), + }); + if (!restartedStatus.ok) throw new Error(`restart health check failed: ${restartedStatus.status}`); + + console.log('PASS REL-06 packaged runtime cold-starts with NODE_ENV=production'); + console.log(`PASS REL-06 isolated database initialized ${initializedTables} tables and restart is idempotent`); +} finally { + await stack.cleanup(); + const artifactAfter = await hashArtifact(runtimeRoot); + if (artifactAfter.sha256 !== artifactBefore.sha256) { + throw new Error( + `packaged runtime mutated its own artifact during cold start: ${artifactBefore.sha256} -> ${artifactAfter.sha256}`, + ); + } +} diff --git a/scripts/run-release-gate-runtime-container.mjs b/scripts/run-release-gate-runtime-container.mjs new file mode 100644 index 0000000..96d5981 --- /dev/null +++ b/scripts/run-release-gate-runtime-container.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +import { assertPortalRuntimePath, inspectPortalRuntime } from '../release-gate/artifact.mjs'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); +const runtime = assertPortalRuntimePath(path.join(root, '.runtime', 'portal'), { repoRoot: root }); +const image = process.env.RELEASE_GATE_NODE_IMAGE || 'node:24-bookworm'; + +async function runDocker(commandArgs, { input = '' } = {}) { + return new Promise((resolve, reject) => { + const child = spawn('docker', commandArgs, { + cwd: root, + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code: code ?? 1, stdout, stderr })); + child.stdin.end(input); + }); +} + +function baseDockerArgs() { + return [ + 'run', + '--rm', + '-i', + '--network', + 'none', + '--mount', + `type=bind,src=${runtime},dst=/opt/portal,readonly`, + '--tmpfs', + '/workspace:rw,noexec,nosuid,size=64m', + image, + ]; +} + +async function requireSuccess(label, args, options) { + const result = await runDocker([...baseDockerArgs(), ...args], options); + if (result.code !== 0) { + throw new Error(`${label} failed (${result.code}): ${result.stderr.slice(-2_000)}`); + } + console.log(`PASS REL-11 ${label}`); + return result.stdout; +} + +const inspection = await inspectPortalRuntime(runtime); +if (inspection.missing.length || inspection.forbidden.length) { + throw new Error( + `runtime inspection failed: missing=${inspection.missing.join(',')} forbidden=${inspection.forbidden.join(',')}`, + ); +} + +await requireSuccess('server bundle parses in Linux ARM64 Node', [ + 'node', + '--check', + '/opt/portal/server.mjs', +]); +await requireSuccess('WeChat bundle parses in Linux ARM64 Node', [ + 'node', + '--check', + '/opt/portal/wechat-mp.bundle.mjs', +]); +await requireSuccess('Agent Run worker loads its runtime dependencies', [ + 'node', + '/opt/portal/scripts/agent-run-worker.mjs', + '--help', +]); + +const initialize = `${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: {}, +})}\n`; +const sandboxOutput = await requireSuccess( + 'sandbox MCP starts and answers initialize', + ['node', '/opt/portal/mindspace-sandbox-mcp.mjs', '/workspace'], + { input: initialize }, +); +if (!sandboxOutput.includes('"name":"mindspace-sandbox"')) { + throw new Error('sandbox MCP initialize response is missing server identity'); +} + +const searchOutput = await requireSuccess( + 'MindSearch MCP starts and answers initialize', + ['node', '/opt/portal/tkmind-search-mcp.mjs'], + { input: initialize }, +); +if (!searchOutput.includes('"name":"tkmind-search"')) { + throw new Error('MindSearch MCP initialize response is missing server identity'); +} + +const excelResult = await runDocker( + [ + ...baseDockerArgs(), + 'env', + 'EXCEL_ANALYST_ENABLED=1', + 'MINDSPACE_WORKSPACE_ROOT=/workspace', + 'node', + '/opt/portal/tkmind-excel-mcp.mjs', + '/workspace', + ], + { input: initialize }, +); +if (excelResult.code !== 0 || !excelResult.stdout.includes('"name":"tkmind-excel"')) { + throw new Error(`Excel MCP failed to start: ${excelResult.stderr.slice(-2_000)}`); +} +console.log('PASS REL-11 Excel MCP starts and answers initialize'); diff --git a/scripts/run-release-gate-runtime-upgrade.mjs b/scripts/run-release-gate-runtime-upgrade.mjs new file mode 100644 index 0000000..4f8e126 --- /dev/null +++ b/scripts/run-release-gate-runtime-upgrade.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import mysql from 'mysql2/promise'; +import pg from 'pg'; + +import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs'; +import { createLocalGateStack } from '../release-gate/local-stack.mjs'; + +const { Client: PgClient } = pg; +const ROOT = path.resolve(new URL('..', import.meta.url).pathname); +const runtimeRoot = assertPortalRuntimePath(path.join(ROOT, '.runtime', 'portal'), { repoRoot: ROOT }); +const artifactBefore = await hashArtifact(runtimeRoot); +const runId = `upgrade-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`; +const markerUserId = '00000000-0000-4000-8000-000000000701'; +const markerUsername = 'gate_upgrade_fixture'; +const markerValue = 'sanitized-page-data-fixture'; + +const stack = await createLocalGateStack({ + root: ROOT, + runId, + port: Number(process.env.MEMIND_RELEASE_GATE_UPGRADE_PORT ?? 19085), + portalRoot: runtimeRoot, + nodeEnv: 'production', + runtimeProfile: 'production', + async beforePortalStart({ mysqlUrl, pgUrl }) { + const schemaSql = await fs.readFile(path.join(ROOT, 'schema.sql'), 'utf8'); + const mysqlConnection = await mysql.createConnection({ + uri: mysqlUrl, + multipleStatements: true, + }); + try { + await mysqlConnection.query(schemaSql); + const now = Date.now(); + await mysqlConnection.query( + `INSERT INTO h5_users + (id, username, slug, email, display_name, salt, password_hash, password_algorithm, + role, status, plan_type, workspace_root, low_balance_gift_eligible, + low_balance_gift_granted_at, created_at, updated_at) + VALUES (?, ?, ?, NULL, ?, ?, ?, 'pbkdf2-sha512', + 'user', 'active', 'free', ?, 0, NULL, ?, ?)`, + [ + markerUserId, + markerUsername, + markerUsername, + '脱敏升级测试用户', + 'fixture-salt', + 'fixture-hash', + `/sanitized/${markerUserId}`, + now, + now, + ], + ); + await mysqlConnection.query('ALTER TABLE h5_users DROP INDEX uq_h5_users_slug'); + await mysqlConnection.query('ALTER TABLE h5_users DROP INDEX uq_h5_users_email'); + await mysqlConnection.query( + `ALTER TABLE h5_users + DROP COLUMN slug, + DROP COLUMN email, + DROP COLUMN password_algorithm, + DROP COLUMN plan_type`, + ); + } finally { + await mysqlConnection.end(); + } + + const pgClient = new PgClient({ connectionString: pgUrl }); + await pgClient.connect(); + try { + await pgClient.query( + `CREATE TABLE gate_sanitized_upgrade_fixture ( + id TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, + ); + await pgClient.query( + 'INSERT INTO gate_sanitized_upgrade_fixture (id, value) VALUES ($1, $2)', + ['fixture-1', markerValue], + ); + } finally { + await pgClient.end(); + } + }, +}); + +try { + async function verifyPreservedState() { + const mysqlConnection = await mysql.createConnection(stack.mysqlUrl); + try { + const [users] = await mysqlConnection.query( + `SELECT username, slug, password_algorithm, plan_type + FROM h5_users + WHERE id = ?`, + [markerUserId], + ); + if (users.length !== 1) throw new Error('sanitized MySQL user fixture was lost'); + if (users[0].slug !== markerUsername) throw new Error('legacy user slug was not migrated'); + if (users[0].password_algorithm !== 'pbkdf2-sha512') { + throw new Error('legacy password algorithm default was not restored'); + } + if (users[0].plan_type !== 'free') throw new Error('legacy plan default was not restored'); + } finally { + await mysqlConnection.end(); + } + + const pgClient = new PgClient({ connectionString: stack.pgUrl }); + await pgClient.connect(); + try { + const result = await pgClient.query( + 'SELECT value FROM gate_sanitized_upgrade_fixture WHERE id = $1', + ['fixture-1'], + ); + if (result.rows[0]?.value !== markerValue) { + throw new Error('sanitized PostgreSQL fixture was lost'); + } + } finally { + await pgClient.end(); + } + } + + await verifyPreservedState(); + await stack.restartPortal(); + await verifyPreservedState(); + console.log('PASS REL-07 legacy MySQL columns migrate and sanitized user data is preserved'); + console.log('PASS REL-07 Page Data PostgreSQL fixture survives two idempotent packaged-runtime starts'); +} finally { + await stack.cleanup(); + const artifactAfter = await hashArtifact(runtimeRoot); + if (artifactAfter.sha256 !== artifactBefore.sha256) { + throw new Error( + `packaged runtime mutated its own artifact during upgrade verification: ${artifactBefore.sha256} -> ${artifactAfter.sha256}`, + ); + } +} diff --git a/scripts/run-release-gate.mjs b/scripts/run-release-gate.mjs new file mode 100644 index 0000000..b700290 --- /dev/null +++ b/scripts/run-release-gate.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import { executeReleaseGate, parseRunnerArgs } from '../release-gate/runner.mjs'; + +function usage() { + console.log(`Usage: + node scripts/run-release-gate.mjs --mode --artifact .runtime/portal [--suite-concurrency 4] + +The runner always fails closed. Unknown, skipped, blocked, failed, or unimplemented +scenarios make the command exit non-zero. Production hosts are rejected in code.`); +} + +try { + const options = parseRunnerArgs(process.argv); + if (options.help) { + usage(); + process.exit(0); + } + const { report, outputDir } = await executeReleaseGate(options); + const summary = report.summary; + console.log(`Release gate report: ${outputDir}`); + console.log(JSON.stringify(summary)); + const passed = summary.failed === 0 + && summary.skipped === 0 + && summary.blocked === 0 + && summary.unknown === 0 + && summary.cleanup_failed === 0 + && summary.passed + summary.not_applicable === summary.required; + process.exit(passed ? 0 : 1); +} catch (error) { + console.error(`Release gate failed: ${error.message}`); + process.exit(1); +} diff --git a/scripts/verify-release-gate-report.mjs b/scripts/verify-release-gate-report.mjs new file mode 100644 index 0000000..ac21d7a --- /dev/null +++ b/scripts/verify-release-gate-report.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs'; +import { validateGateReport } from '../release-gate/report.mjs'; +import { runCommand } from '../release-gate/runner.mjs'; + +const ROOT = path.resolve(new URL('..', import.meta.url).pathname); + +function parseArgs(argv) { + const options = { + artifact: path.join(ROOT, '.runtime', 'portal'), + report: null, + }; + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--artifact') options.artifact = path.resolve(argv[++index] ?? ''); + else if (arg === '--report') options.report = path.resolve(argv[++index] ?? ''); + else throw new Error(`Unknown argument: ${arg}`); + } + return options; +} + +async function git(...args) { + const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 }); + if (result.code !== 0) throw new Error(`git ${args.join(' ')} failed`); + return result.stdout.trim(); +} + +try { + const options = parseArgs(process.argv); + options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT }); + const commitSha = await git('rev-parse', 'HEAD'); + const reportPath = options.report ?? path.join(ROOT, '.release-gate', commitSha, 'report.json'); + const [raw, artifact] = await Promise.all([ + fs.readFile(reportPath, 'utf8'), + hashArtifact(options.artifact), + ]); + const report = JSON.parse(raw); + const validation = validateGateReport(report, { + expectedCommit: commitSha, + expectedArtifactSha256: artifact.sha256, + expectedBranch: 'main', + }); + if (!validation.valid) { + const blocking = report.scenarios.filter( + (scenario) => !['passed', 'not_applicable'].includes(scenario.status), + ); + const preview = blocking + .slice(0, 20) + .map((scenario) => `${scenario.id}:${scenario.status}`) + .join(', '); + const structural = validation.errors.filter( + (message) => !/^[A-Z]+-\d{2} is (failed|skipped|blocked|unknown)$/.test(message), + ); + throw new Error([ + 'Gate report rejected.', + `blocking_scenarios=${blocking.length}`, + `preview=${preview}${blocking.length > 20 ? ', ...' : ''}`, + ...structural.map((message) => `- ${message}`), + ].join('\n')); + } + console.log(`Gate report verified: ${reportPath}`); + console.log(`commit=${commitSha}`); + console.log(`artifact_sha256=${artifact.sha256}`); +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/server/portal-agent-services-bootstrap.mjs b/server/portal-agent-services-bootstrap.mjs index e5b5dfc..601a7e3 100644 --- a/server/portal-agent-services-bootstrap.mjs +++ b/server/portal-agent-services-bootstrap.mjs @@ -185,21 +185,23 @@ export async function bootstrapPortalAgentServices({ const wordFilterService = createWordFilterServiceFn(pool); - const relayBootstrapTask = llmProviderService - .ensureBootstrapRelay() - .then((result) => { - if (result.created) { - logger.log( - `LLM relay bootstrap created: ${relayBootstrap.name}`, + const relayBootstrapTask = env.H5_RELAY_BOOTSTRAP_DISABLED === '1' + ? Promise.resolve({ ok: true, created: false, skipped: true }) + : llmProviderService + .ensureBootstrapRelay() + .then((result) => { + if (result.created) { + logger.log( + `LLM relay bootstrap created: ${relayBootstrap.name}`, + ); + } + }) + .catch((error) => { + logger.warn( + 'LLM relay bootstrap skipped:', + error instanceof Error ? error.message : error, ); - } - }) - .catch((error) => { - logger.warn( - 'LLM relay bootstrap skipped:', - error instanceof Error ? error.message : error, - ); - }); + }); void relayBootstrapTask; const providerSyncTask = llmProviderService diff --git a/server/portal-agent-services-bootstrap.test.mjs b/server/portal-agent-services-bootstrap.test.mjs index 6ce6f62..2bd5e5a 100644 --- a/server/portal-agent-services-bootstrap.test.mjs +++ b/server/portal-agent-services-bootstrap.test.mjs @@ -410,3 +410,17 @@ test('keeps disabled experience null and reports background LLM failures', async ), ); }); + +test('can disable relay bootstrap for backend-LLM-only isolated runs', async () => { + const setup = createSetup({ + env: { + EXPERIENCE_PG_URL: 'postgres://experience', + H5_RELAY_BOOTSTRAP_DISABLED: '1', + }, + }); + const result = await bootstrapPortalAgentServices(setup.options); + await Promise.all([result.relayBootstrapTask, result.providerSyncTask]); + assert.equal(result.relayBootstrapTask instanceof Promise, true); + assert.equal(setup.calls.some(([name]) => name === 'relay-bootstrap'), false); + assert.equal(setup.calls.some(([name]) => name === 'provider-sync'), true); +}); diff --git a/user-publish.mjs b/user-publish.mjs index cf6493c..25f2814 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -361,6 +361,8 @@ export function buildSandboxSessionConstraints({ baseConstraints, developerTools const hasSandboxMcp = tools.includes('write_file') || tools.includes('read_file'); const hasShell = tools.includes('shell'); const hasListDir = tools.includes('list_dir') || tools.includes('tree'); + const writeTool = hasSandboxMcp ? 'sandbox-fs__write_file' : 'write_file'; + const editTool = hasSandboxMcp ? 'sandbox-fs__edit_file' : 'edit_file'; const lines = [ baseConstraints, @@ -396,7 +398,7 @@ export function buildSandboxSessionConstraints({ baseConstraints, developerTools lines.push( '', '## 生成 / 发布 HTML 页面', - '- 你有 write_file/edit_file 工具:**必须由你**写入 `public/xxx.html`(或工作区根目录 `.html`)', + `- 你有 ${writeTool}/${editTool} 工具:**必须由你**写入 \`public/xxx.html\`(或工作区根目录 \`.html\`);沙箱 MCP 使用带 \`sandbox-fs__\` 前缀的工具名,禁止调用不存在的裸 \`write_file\``, '- 开始前执行 load_skill → `static-page-publish`,按技能说明写入 mindspace-cover 元数据', '- `generate_image` 成功后,HTML 的图片与背景只能使用返回的 `asset.htmlSrc`;`workspaceRelativePath` 只用于文件操作,禁止直接写入 HTML', '- 默认只生成 HTML;不要在没有明确需求时强制生成 Word、PDF、长图等伴生文件', diff --git a/user-publish.test.mjs b/user-publish.test.mjs index 6fdcae3..7a6e3d8 100644 --- a/user-publish.test.mjs +++ b/user-publish.test.mjs @@ -148,6 +148,16 @@ test('buildSandboxSessionConstraints documents shell and forbids public url brow assert.match(text, /write_file\/edit_file 会直接拒绝/); }); +test('sandbox MCP constraints use namespaced file tools', () => { + const text = buildSandboxSessionConstraints({ + baseConstraints: 'base', + developerTools: ['write_file', 'edit_file', 'read_file'], + }); + assert.match(text, /sandbox-fs__write_file/); + assert.match(text, /sandbox-fs__edit_file/); + assert.match(text, /禁止调用不存在的裸 `write_file`/); +}); + test('renderWorkspaceHints includes docx download guidance for published pages', () => { const text = renderWorkspaceHints({ slug: USER_ID,