diff --git a/demohouse/sales-intelligence-workbench/.github/workflows/ci.yml b/demohouse/sales-intelligence-workbench/.github/workflows/ci.yml index d3712936..f2cb01b9 100644 --- a/demohouse/sales-intelligence-workbench/.github/workflows/ci.yml +++ b/demohouse/sales-intelligence-workbench/.github/workflows/ci.yml @@ -9,7 +9,12 @@ permissions: jobs: test: - runs-on: ubuntu-latest + name: Node 20 / ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} timeout-minutes: 15 steps: - uses: actions/checkout@v4 diff --git a/demohouse/sales-intelligence-workbench/CHANGELOG.md b/demohouse/sales-intelligence-workbench/CHANGELOG.md index 3ba3914a..4002e9b3 100644 --- a/demohouse/sales-intelligence-workbench/CHANGELOG.md +++ b/demohouse/sales-intelligence-workbench/CHANGELOG.md @@ -1,6 +1,18 @@ # 变更记录 -本文件记录各发行版本的公开变化。当前版本为 `0.10.0` 自托管开源版。 +本文件记录各发行版本的公开变化。当前版本为 `0.10.1` 自托管开源版。 + +## 0.10.1 - 2026-09-03 + +- 将火山方舟 AI App Lab 中的 Skill 设为统一公开入口,同时把真实安装包固定到独立发行仓库的版本化 tag。 +- 远程初始化不再根据入口 URL 猜测下载仓库或项目根目录,避免从 monorepo 根目录执行销售工作台安装命令。 +- 增加官方入口、固定发行源和双端隔离安装校验;下载、校验和安装阶段仍不调用外部 Provider 或产生 AFP。 +- 增加固定仓库、本地与远程 tag/commit、ignored 残留和干净工作区校验器;远程入口只允许全新临时 clone,不复用已有目录。 +- 发布后验收会确认 AI App Lab 固定 commit 已进入 `main`,并比较官方完整项目镜像,而非只比较入口文件。 +- 独立发行要求 GitHub immutable Release,阻止 `v0.10.1` tag 或发行资产在宣传后被替换。 +- Node 20 发布 CI 扩展为 Ubuntu、macOS 和 Windows 三平台矩阵,任一平台失败都不进入发布。 +- 公开口令生成器拒绝浮动 ref、错误仓库、错误版本和错误 Skill 路径;重大宣传支持固定到 AI App Lab 合并 commit。 +- 运行时安装元数据记录发行版本和应用包内容哈希;安装测试后再次核对,后续 status 与业务脚本持续检查实际运行包未被替换或篡改。 ## 0.10.0 - 2026-08-03 diff --git a/demohouse/sales-intelligence-workbench/README.md b/demohouse/sales-intelligence-workbench/README.md index 0457bd02..21347cd7 100644 --- a/demohouse/sales-intelligence-workbench/README.md +++ b/demohouse/sales-intelligence-workbench/README.md @@ -2,7 +2,7 @@ 销售智能工作台把企业专业数据、公开信息、飞书资料和长期记忆组织成可追溯的企业档案与资料问答。项目包含完整前后端、AI Native 应用开发底座(Supabase)数据层、Agent 记忆(OpenViking)链路,以及可在 Codex 和 Claude Code 中安装并运维应用的同一份 Skill。 -> 当前为 `0.10.0` 自托管开源版,支持单工作区、单管理员,以及本机或受控内网部署。已支持 Supabase Auth、工作区数据隔离、安全请求边界、工作区级付费任务保护、持久化异步任务队列和独立 Worker。当前版本不提供公网托管 SaaS、多人协作或 SLA;不要把 Node.js 服务端口直接暴露到公网。 +> 当前为 `0.10.1` 自托管开源版,支持单工作区、单管理员,以及本机或受控内网部署。已支持 Supabase Auth、工作区数据隔离、安全请求边界、工作区级付费任务保护、持久化异步任务队列和独立 Worker。当前版本不提供公网托管 SaaS、多人协作或 SLA;不要把 Node.js 服务端口直接暴露到公网。 ## 核心能力 @@ -52,23 +52,59 @@ AI Native 应用开发底座(Supabase)是结构化业务事实库;Agent ### 面向最终用户:一句话初始化 -当前独立发行仓库的版本化初始化入口为: +火山方舟 AI App Lab 中的官方初始化入口为: -> 帮我初始化销售助手:`https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v0.10.0/skills/sales-intelligence-workbench/SKILL.md` +> 帮我初始化销售助手:`https://github.com/volcengine/ai-app-lab/blob/main/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md` -该 URL 直接指向唯一的正式 Skill。即使用户本机没有仓库、依赖和配置文件,当前 Agent 也会 -先解释下载与本机写入影响,再从 URL 指定的版本取得完整仓库,执行离线校验,并把同一份 -Skill 安装到当前客户端后立即衔接下面的 Cookbook 搭建流程。读取 URL、下载仓库和离线校验 -不会创建云资源或产生 AFP。 +上面的 `main` 地址用于日常获取官方最新版本。重大宣传必须在 AI App Lab PR 合并后,把 +`main` 替换为该次合并的完整 40 位 commit SHA,确保活动期间入口内容不会漂移。 -初始化入口必须固定到已发布的 tag 或经过审核的 commit SHA。也可以用仓库脚本生成口令: +该 URL 只负责提供官方入口。Skill 会固定从独立发行仓库的 `v0.10.1` tag 获取完整安装包, +不会把 AI App Lab 的 `main` 分支当作应用发行版本,也不会下载整个 monorepo 后猜测项目目录。 +即使用户本机没有仓库、依赖和配置文件,当前 Agent 也会先解释下载与本机写入影响,再取得 +经过验证的固定版本,执行离线校验,并把同一份 Skill 安装到当前客户端后立即衔接下面的 +Cookbook 搭建流程。读取入口、下载仓库和离线校验不会创建云资源或产生 AFP。 + +固定发行源为: + +```text +固定发行仓库:https://github.com/3494036618-eng/sales-intelligence-workbench/tree/v0.10.1 +固定发行 Skill:https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v0.10.1/skills/sales-intelligence-workbench/SKILL.md +``` + +维护者可以用仓库脚本生成官方口令: + +```bash +npm run skill:command -- --official +npm run skill:command -- --official-ref +``` + +`--official` 只生成日常最新入口;第二条命令生成重大宣传使用的固定入口。生成器默认只允许 +当前独立发行仓库、当前版本和 AI App Lab 正式路径,`HEAD`、分支名、错误仓库、错误版本和 +错误 Skill 路径都会被拒绝。 + +维护者发布 `v0.10.1` tag 和正式 GitHub Release 后,必须开启 Release 的 immutable 属性; +发布前分支的 Node 20 CI 必须在 Ubuntu、macOS 和 Windows 三个平台全部通过。提交 +AI App Lab PR 前,再从 GitHub 全新克隆并验收真实发行包: + +```bash +npm run release:verify:public +``` + +AI App Lab PR 合并后,再验证官方固定 commit 已进入 `main`,并核对完整项目镜像与 +`UPSTREAM.json`: ```bash -npm run skill:command -- \ - --repository https://github.com/3494036618-eng/sales-intelligence-workbench \ - --ref v0.10.0 +npm run release:verify:public -- --official-ref ``` +两次命令都通过之前不得对外宣传。它们会访问公开 GitHub、使用隔离临时目录运行完整 +`npm run verify`,由维护者发布门校验 immutable Release,再校验远程 tag、commit、官方完整目录(含 Git mode) +和安装包身份后自动清理;不会调用 +Agent Plan、创建云资源或产生 AFP。 +immutable 状态只在发布门集中验证,最终用户安装时不额外调用 GitHub Release API,避免大型 +活动共用出口触发匿名 API 限流;用户下载的 tag 已由通过门禁的 immutable Release 锁定。 + 这里的“从 0 搭建”不等于绕过第三方服务授权:用户仍需拥有 Agent Plan,并在控制台开启 专业数据集(DataPro)、豆包搜索(联网搜索)、Agent 记忆(OpenViking)和 AI Native 应用开发底座(Supabase),再按需完成飞书资料授权。用户侧只输入一枚 @@ -249,6 +285,8 @@ node skills/sales-intelligence-workbench/scripts/import-feishu.mjs \ --doc "https://example.feishu.cn/wiki/..." ``` +> 文档中的 `0600`/`0700` 是 macOS/Linux 权限表达。Windows 下文件保存在当前用户的配置或状态目录,继承该用户目录的 NTFS ACL;运行时同时拒绝符号链接和非普通文件。 + 会话导入支持 `--p2p-user <联系人姓名>` 或 `--chat-id `;云文档只接受完整链接。启用 `FEISHU_CLI_IMPORT_ENABLED=true` 后,登录用户也可以在“历史资料”模块点击“导入飞书资料”,选择会话或云文档并查看本机任务进度。两种入口调用同一条受控链路:正文只写入 Agent 记忆(OpenViking),AI Native 应用开发底座(Supabase)只保存来源、内容指纹、增量游标和 OpenViking 引用。详见 [飞书导入说明](skills/sales-intelligence-workbench/references/feishu-import.md)。 ## 运维 diff --git a/demohouse/sales-intelligence-workbench/SECURITY.md b/demohouse/sales-intelligence-workbench/SECURITY.md index 9e7d4763..50345aa0 100644 --- a/demohouse/sales-intelligence-workbench/SECURITY.md +++ b/demohouse/sales-intelligence-workbench/SECURITY.md @@ -2,7 +2,7 @@ ## Supported scope -The current `v0.10.0` self-hosted open-source release supports one workspace and one local administrator. It includes Supabase Auth, browser and CLI sessions, CSRF protection for cookie-authenticated mutations, and an internal account binding for workspace isolation. It does not expose public registration, email confirmation, password-recovery email, member, or role-management flows. +The current `v0.10.1` self-hosted open-source release supports one workspace and one local administrator. It includes Supabase Auth, browser and CLI sessions, CSRF protection for cookie-authenticated mutations, and an internal account binding for workspace isolation. It does not expose public registration, email confirmation, password-recovery email, member, or role-management flows. This is not a managed multi-tenant SaaS release. The default listener remains `127.0.0.1`. Do not expose the backend port directly to the public Internet; place the application behind an HTTPS reverse proxy and complete the deployment checks below. diff --git a/demohouse/sales-intelligence-workbench/UPSTREAM.json b/demohouse/sales-intelligence-workbench/UPSTREAM.json index 5672f6f3..21e3fb25 100644 --- a/demohouse/sales-intelligence-workbench/UPSTREAM.json +++ b/demohouse/sales-intelligence-workbench/UPSTREAM.json @@ -1,6 +1,6 @@ { - "repository": "https://github.com/3494036618-eng/sales-intelligence-workbench/", - "commit": "c9e3fe70824eae62d810d1d02186f616c4d2a041", - "version": "0.10.0", - "synced_at": "2026-08-21" + "repository": "https://github.com/3494036618-eng/sales-intelligence-workbench", + "commit": "fcd9907f89a8cc21d360384c40d19c4be2c6c45d", + "version": "0.10.1", + "synced_at": "2026-09-03" } diff --git a/demohouse/sales-intelligence-workbench/backend/package.json b/demohouse/sales-intelligence-workbench/backend/package.json index 6991476b..d6d6df76 100644 --- a/demohouse/sales-intelligence-workbench/backend/package.json +++ b/demohouse/sales-intelligence-workbench/backend/package.json @@ -1,11 +1,11 @@ { "name": "sales-intelligence-workbench-api", - "version": "0.10.0", + "version": "0.10.1", "private": true, "type": "module", "scripts": { "dev": "node src/server.js", - "test": "node --test tests/*.test.mjs", + "test": "node scripts/run-tests.mjs", "release:secrets": "node scripts/check-release-secrets.mjs", "release:verify": "node scripts/verify-release-local.mjs", "doctor": "node scripts/doctor.mjs", diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/export-workspace.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/export-workspace.mjs index d2bea44a..9d420aec 100644 --- a/demohouse/sales-intelligence-workbench/backend/scripts/export-workspace.mjs +++ b/demohouse/sales-intelligence-workbench/backend/scripts/export-workspace.mjs @@ -55,8 +55,13 @@ function parseArgs(argv) { function assertPrivateSession(filePath) { const session = readAuthSession(filePath); if (!session) throw new Error("未找到 CLI 登录态。请先运行 Skill 的 login.mjs。"); - const mode = fs.statSync(filePath).mode & 0o077; - if (mode !== 0) throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + const status = fs.lstatSync(filePath); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error("CLI 会话路径必须是非符号链接的普通文件。"); + } + if (process.platform !== "win32" && (status.mode & 0o077) !== 0) { + throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + } return session; } diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/run-tests.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/run-tests.mjs new file mode 100644 index 00000000..35b38711 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/backend/scripts/run-tests.mjs @@ -0,0 +1,49 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const backendDir = path.resolve(scriptDir, ".."); +const testsDir = path.join(backendDir, "tests"); +const testFiles = fs.readdirSync(testsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".test.mjs")) + .map((entry) => path.join("tests", entry.name)) + .sort((left, right) => left.localeCompare(right, "en")); + +if (!testFiles.length) throw new Error("未找到后端测试文件。"); + +const result = spawnSync(process.execPath, ["--test", ...testFiles], { + cwd: backendDir, + encoding: "utf8", + env: process.env, + maxBuffer: 4 * 1024 * 1024, +}); +if (result.error) throw result.error; +if (result.status === 0) { + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); +} else { + const lines = String(result.stdout || "").split(/\r?\n/); + const failureBlocks = []; + for (let index = 0; index < lines.length; index += 1) { + if (!/^not ok \d+ - /.test(lines[index])) continue; + const start = index > 0 && lines[index - 1].startsWith("# Subtest:") ? index - 1 : index; + let end = index + 1; + while (end < lines.length && !lines[end].startsWith("# Subtest:") && !/^1\.\./.test(lines[end])) { + end += 1; + } + failureBlocks.push(lines.slice(start, end).join("\n").trimEnd()); + index = end - 1; + } + const summary = lines.filter((line) => /^# (?:tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b/.test(line)); + process.stdout.write([ + "后端测试未通过,仅显示失败项与汇总:", + ...failureBlocks, + ...summary, + "", + ].join("\n")); + process.stderr.write(result.stderr); + process.exitCode = result.status || 1; +} diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/verify-business-chain.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/verify-business-chain.mjs index 6e08db44..01ff7af4 100644 --- a/demohouse/sales-intelligence-workbench/backend/scripts/verify-business-chain.mjs +++ b/demohouse/sales-intelligence-workbench/backend/scripts/verify-business-chain.mjs @@ -202,8 +202,13 @@ function assertPrivateSession(filePath) { if (!session) { throw new Error("未找到有效 CLI 登录态。请先运行 Skill 的 login.mjs,密码不要发送到聊天或命令行参数。"); } - const mode = fs.statSync(filePath).mode & 0o077; - if (mode !== 0) throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + const status = fs.lstatSync(filePath); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error("CLI 会话路径必须是非符号链接的普通文件。"); + } + if (process.platform !== "win32" && (status.mode & 0o077) !== 0) { + throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + } return session; } diff --git a/demohouse/sales-intelligence-workbench/backend/scripts/verify-release-local.mjs b/demohouse/sales-intelligence-workbench/backend/scripts/verify-release-local.mjs index 8b4f1e57..be9e3e7c 100644 --- a/demohouse/sales-intelligence-workbench/backend/scripts/verify-release-local.mjs +++ b/demohouse/sales-intelligence-workbench/backend/scripts/verify-release-local.mjs @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; @@ -6,7 +7,24 @@ import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const backendDir = path.resolve(scriptDir, ".."); const projectRoot = path.resolve(backendDir, ".."); -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +function npmInvocation(args) { + const cli = [ + process.env.npm_execpath, + path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), + ].find((candidate) => candidate && /\.(?:c?m?js)$/i.test(candidate) && fs.existsSync(candidate)); + if (cli) return { command: process.execPath, args: [cli, ...args] }; + if (process.platform === "win32") { + return { + command: process.env.ComSpec || "cmd.exe", + args: ["/d", "/s", "/c", ["npm", ...args].join(" ")], + }; + } + return { command: "npm", args }; +} + +const npmTest = npmInvocation(["test"]); +const npmSecretScan = npmInvocation(["run", "release:secrets"]); const steps = [ { @@ -23,14 +41,14 @@ const steps = [ }, { name: "后端自动化测试", - command: npmCommand, - args: ["test"], + command: npmTest.command, + args: npmTest.args, cwd: backendDir, }, { name: "发布密钥扫描", - command: npmCommand, - args: ["run", "release:secrets"], + command: npmSecretScan.command, + args: npmSecretScan.args, cwd: backendDir, }, { @@ -70,6 +88,15 @@ try { steps.forEach(runStep); console.log(`\n离线发布验收通过:${steps.length}/${steps.length} 项完成。`); } catch (error) { - console.error(`\n离线发布验收失败:${error?.message || String(error)}`); + const message = `离线发布验收失败:${error?.message || String(error)}`; + console.error(`\n${message}`); + if (process.env.GITHUB_ACTIONS === "true") { + const annotation = message + .split(projectRoot).join("") + .replace(/%/g, "%25") + .replace(/\r/g, "%0D") + .replace(/\n/g, "%0A"); + process.stderr.write(`::error title=Offline release verification failure::${annotation}\n`); + } process.exitCode = 1; } diff --git a/demohouse/sales-intelligence-workbench/backend/src/config/providerConfig.js b/demohouse/sales-intelligence-workbench/backend/src/config/providerConfig.js index 5c7cbadb..7b0ec1ac 100644 --- a/demohouse/sales-intelligence-workbench/backend/src/config/providerConfig.js +++ b/demohouse/sales-intelligence-workbench/backend/src/config/providerConfig.js @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { delimiter, isAbsolute, join } from "node:path"; import { createEnvReader } from "./runtimeEnv.js"; import { createRuntimePolicy, publicRuntimePolicy } from "./runtimePolicy.js"; @@ -17,7 +17,7 @@ function unique(values) { function commandExists(command) { const value = String(command || "").trim(); if (!value) return false; - if (value.includes("/")) return existsSync(value); + if (isAbsolute(value) || value.includes("/") || value.includes("\\")) return existsSync(value); return String(process.env.PATH || "") .split(delimiter) .filter(Boolean) diff --git a/demohouse/sales-intelligence-workbench/backend/src/providers/openVikingProvider.js b/demohouse/sales-intelligence-workbench/backend/src/providers/openVikingProvider.js index 6409dd13..ce088625 100644 --- a/demohouse/sales-intelligence-workbench/backend/src/providers/openVikingProvider.js +++ b/demohouse/sales-intelligence-workbench/backend/src/providers/openVikingProvider.js @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { delimiter, isAbsolute, join } from "node:path"; import { promisify } from "node:util"; import { createEnvReader } from "../config/runtimeEnv.js"; import { providerFailure, providerSuccess } from "./providerResult.js"; @@ -48,7 +48,7 @@ function defaultCliConfigPath() { function commandExists(command) { const value = String(command || "").trim(); if (!value) return false; - if (value.includes("/")) return existsSync(value); + if (isAbsolute(value) || value.includes("/") || value.includes("\\")) return existsSync(value); return String(process.env.PATH || "") .split(delimiter) .filter(Boolean) diff --git a/demohouse/sales-intelligence-workbench/backend/src/routes/index.js b/demohouse/sales-intelligence-workbench/backend/src/routes/index.js index a9d23924..fc8ef95b 100644 --- a/demohouse/sales-intelligence-workbench/backend/src/routes/index.js +++ b/demohouse/sales-intelligence-workbench/backend/src/routes/index.js @@ -79,7 +79,7 @@ export function createRouter(providerService, options = {}) { data: { status: runtimePolicy.ready ? "ok" : "degraded", service: "sales-intelligence-workbench-api", - version: "0.10.0", + version: "0.10.1", provider_mode: providerMode, runtime_ready: runtimePolicy.ready, }, diff --git a/demohouse/sales-intelligence-workbench/backend/tests/feishuImportScript.test.mjs b/demohouse/sales-intelligence-workbench/backend/tests/feishuImportScript.test.mjs index bf68e82c..ef2ae5a9 100644 --- a/demohouse/sales-intelligence-workbench/backend/tests/feishuImportScript.test.mjs +++ b/demohouse/sales-intelligence-workbench/backend/tests/feishuImportScript.test.mjs @@ -60,7 +60,10 @@ test("backend requests refresh an expired bearer session and rotate the private const rotated = JSON.parse(fs.readFileSync(sessionFile, "utf8")); assert.equal(rotated.access_token, "fresh-access"); assert.equal(rotated.refresh_token, "rotated-refresh"); - assert.equal(fs.statSync(sessionFile).mode & 0o077, 0); + const sessionStatus = fs.lstatSync(sessionFile); + assert.equal(sessionStatus.isFile(), true); + assert.equal(sessionStatus.isSymbolicLink(), false); + if (process.platform !== "win32") assert.equal(sessionStatus.mode & 0o077, 0); } finally { globalThis.fetch = originalFetch; fs.rmSync(directory, { recursive: true, force: true }); diff --git a/demohouse/sales-intelligence-workbench/docs/deployment/self-hosting.md b/demohouse/sales-intelligence-workbench/docs/deployment/self-hosting.md index 36927da6..23042918 100644 --- a/demohouse/sales-intelligence-workbench/docs/deployment/self-hosting.md +++ b/demohouse/sales-intelligence-workbench/docs/deployment/self-hosting.md @@ -1,6 +1,6 @@ # 单工作区自托管部署 -本文说明 `0.10.0` 自托管开源版的支持边界和推荐部署方式。当前版本支持单工作区、单管理员,以及本机或受控内网部署;不提供公网托管 SaaS、多人协作或 SLA。公网开放前必须完成正式域名、HTTPS 和真实链路验收。 +本文说明 `0.10.1` 自托管开源版的支持边界和推荐部署方式。当前版本支持单工作区、单管理员,以及本机或受控内网部署;不提供公网托管 SaaS、多人协作或 SLA。公网开放前必须完成正式域名、HTTPS 和真实链路验收。 ## 1. 进程与网络边界 diff --git a/demohouse/sales-intelligence-workbench/package-lock.json b/demohouse/sales-intelligence-workbench/package-lock.json index bce3f64d..dc190782 100644 --- a/demohouse/sales-intelligence-workbench/package-lock.json +++ b/demohouse/sales-intelligence-workbench/package-lock.json @@ -1,12 +1,12 @@ { "name": "sales-intelligence-workbench-oss", - "version": "0.10.0", + "version": "0.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sales-intelligence-workbench-oss", - "version": "0.10.0", + "version": "0.10.1", "engines": { "node": ">=20" } diff --git a/demohouse/sales-intelligence-workbench/package.json b/demohouse/sales-intelligence-workbench/package.json index 23dbb0e0..4151c1fd 100644 --- a/demohouse/sales-intelligence-workbench/package.json +++ b/demohouse/sales-intelligence-workbench/package.json @@ -1,10 +1,13 @@ { "name": "sales-intelligence-workbench-oss", - "version": "0.10.0", + "version": "0.10.1", "private": true, "type": "module", "scripts": { "release:validate": "node scripts/validate-public-release.mjs", + "release:validate:checkout": "node scripts/validate-release-checkout.mjs", + "release:test:checkout": "node scripts/test-release-checkout.mjs", + "release:verify:public": "node scripts/verify-public-install.mjs", "skill:install": "node scripts/install-codex-skill.mjs", "skill:install:codex": "node scripts/install-codex-skill.mjs", "skill:install:claude": "node scripts/install-claude-code-skill.mjs", @@ -12,7 +15,7 @@ "skill:command": "node scripts/print-public-skill-command.mjs", "skill:validate": "node scripts/validate-skill-package.mjs", "skill:test": "node scripts/test-skill-installer.mjs", - "verify": "npm run release:validate && npm run skill:validate && npm run skill:test && npm --prefix backend run release:verify" + "verify": "npm run release:validate && npm run release:test:checkout && npm run skill:validate && npm run skill:test && npm --prefix backend run release:verify" }, "engines": { "node": ">=20" diff --git a/demohouse/sales-intelligence-workbench/scripts/install-agent-skill.mjs b/demohouse/sales-intelligence-workbench/scripts/install-agent-skill.mjs index 8b9caf24..9413d88d 100644 --- a/demohouse/sales-intelligence-workbench/scripts/install-agent-skill.mjs +++ b/demohouse/sales-intelligence-workbench/scripts/install-agent-skill.mjs @@ -7,6 +7,15 @@ import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const skillName = "sales-intelligence-workbench"; const source = path.join(root, "skills", skillName); +const requiredInstalledFiles = [ + "SKILL.md", + "scripts/onboard.mjs", + "assets/app/backend/package.json", + "assets/app/backend/src/server.js", + "assets/app/frontend/index.html", + "assets/app/frontend/app.js", + "assets/app/supabase/migrations", +]; const targetDefinitions = { codex: { @@ -26,17 +35,14 @@ const targetDefinitions = { }; function copyFilter(entry) { - const relative = path.relative(source, entry); - if (!relative) return true; - const segments = relative.split(path.sep); const name = path.basename(entry); - return !segments.some((segment) => [ + return ![ "node_modules", "dist", ".git", ".temp", "coverage", - ].includes(segment)) + ].includes(name) && name !== ".DS_Store" && !(name.startsWith(".env.") && name !== ".env.example") && name !== ".env" @@ -87,6 +93,12 @@ function installOne(definition, force) { force: true, filter: copyFilter, }); + const missing = requiredInstalledFiles.filter((relativePath) => ( + !fs.existsSync(path.join(staging, relativePath)) + )); + if (missing.length) { + throw new Error(`Skill 复制不完整:缺少 ${missing.join("、")}`); + } fs.rmSync(backup, { recursive: true, force: true }); if (fs.existsSync(definition.target)) { if (!force) { diff --git a/demohouse/sales-intelligence-workbench/scripts/print-public-skill-command.mjs b/demohouse/sales-intelligence-workbench/scripts/print-public-skill-command.mjs index 49cfe0c3..8aa170dc 100644 --- a/demohouse/sales-intelligence-workbench/scripts/print-public-skill-command.mjs +++ b/demohouse/sales-intelligence-workbench/scripts/print-public-skill-command.mjs @@ -4,6 +4,11 @@ import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); +const canonicalRepository = "https://github.com/3494036618-eng/sales-intelligence-workbench"; +const canonicalSkillPath = "skills/sales-intelligence-workbench/SKILL.md"; +const officialRepository = "https://github.com/volcengine/ai-app-lab"; +const officialSkillPath = "demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md"; +const officialEntryUrl = "https://github.com/volcengine/ai-app-lab/blob/main/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md"; function option(name) { const index = process.argv.indexOf(name); @@ -18,30 +23,61 @@ function usage() { 生成销售助手公开初始化口令 用法: + node scripts/print-public-skill-command.mjs --official + + node scripts/print-public-skill-command.mjs --official-ref <40位commit SHA> + node scripts/print-public-skill-command.mjs \\ --repository https://github.com// \\ --ref v${packageJson.version} \\ - [--skill-path skills/sales-intelligence-workbench/SKILL.md] + [--skill-path skills/sales-intelligence-workbench/SKILL.md] \\ + [--allow-custom-repository] 说明: + - --official 生成 AI App Lab 的 main 最新入口;重大宣传应在 PR 合并后使用 --official-ref 固定到合并 commit。 + - --official-ref 只接受完整 40 位 commit SHA。 - --repository 必须是公开 GitHub 仓库根地址。 - - --ref 必须是已发布的不可变 tag 或 commit;正式发布不要使用 main。 + - --ref 只接受 vX.Y.Z release tag 或完整 40 位 commit SHA。 - --skill-path 是 Skill 在仓库内的相对路径;默认适用于独立仓库。 + - 默认只允许本项目独立发行地址或 AI App Lab 官方路径;测试 fork 必须显式使用 --allow-custom-repository。 - 本命令只生成文字,不访问网络、不修改文件。 `.trimStart(); } +function main() { if (process.argv.includes("--help") || process.argv.includes("-h")) { process.stdout.write(usage()); process.exit(0); } +if (process.argv.includes("--official")) { + if (process.argv.length !== 3) { + throw new Error("--official 不能与其他参数组合;固定宣传版本请使用 --official-ref。"); + } + process.stdout.write(`帮我初始化销售助手:${officialEntryUrl}\n`); + process.exit(0); +} + +const officialRef = option("--official-ref"); +if (officialRef) { + if (!/^[0-9a-f]{40}$/i.test(officialRef)) { + throw new Error("--official-ref 必须是完整 40 位 commit SHA。"); + } + if (process.argv.length !== 4) { + throw new Error("--official-ref 不能与其他参数组合。"); + } + const url = `${officialRepository}/blob/${officialRef}/${officialSkillPath}`; + process.stdout.write(`帮我初始化销售助手:${url}\n`); + process.exit(0); +} + const repository = option("--repository"); const ref = option("--ref"); -const skillPath = option("--skill-path") || "skills/sales-intelligence-workbench/SKILL.md"; +const skillPath = option("--skill-path") || canonicalSkillPath; +const allowCustomRepository = process.argv.includes("--allow-custom-repository"); if (!repository || !ref) throw new Error("必须同时提供 --repository 和 --ref。"); -if (ref === "main" || ref === "master") { - throw new Error("正式初始化口令必须固定到 release tag 或 commit,不能使用 main/master。"); +if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(ref) && !/^[0-9a-f]{40}$/i.test(ref)) { + throw new Error("正式初始化口令必须使用 vX.Y.Z release tag 或完整 40 位 commit SHA。"); } let url; @@ -60,6 +96,7 @@ if (segments.length !== 2) { } const [owner, repo] = segments; +const normalizedRepository = `https://github.com/${owner}/${repo}`; const skillPathSegments = skillPath.split("/").filter(Boolean); if ( skillPath.startsWith("/") @@ -69,6 +106,25 @@ if ( ) { throw new Error("--skill-path 必须是仓库内以 SKILL.md 结尾的安全相对路径。"); } +if (!allowCustomRepository) { + const isCanonicalRelease = normalizedRepository === canonicalRepository + && skillPath === canonicalSkillPath + && ref === `v${packageJson.version}`; + const isOfficialCommit = normalizedRepository === officialRepository + && skillPath === officialSkillPath + && /^[0-9a-f]{40}$/i.test(ref); + if (!isCanonicalRelease && !isOfficialCommit) { + throw new Error("仓库、版本或 Skill 路径不属于当前固定发行链路;测试 fork 必须显式追加 --allow-custom-repository。"); + } +} const encodedSkillPath = skillPathSegments.map((segment) => encodeURIComponent(segment)).join("/"); const entryUrl = `https://github.com/${owner}/${repo}/blob/${encodeURIComponent(ref)}/${encodedSkillPath}`; process.stdout.write(`帮我初始化销售助手:${entryUrl}\n`); +} + +try { + main(); +} catch (error) { + process.stderr.write(`错误:${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/demohouse/sales-intelligence-workbench/scripts/test-release-checkout.mjs b/demohouse/sales-intelligence-workbench/scripts/test-release-checkout.mjs new file mode 100644 index 00000000..016ef5f9 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/scripts/test-release-checkout.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + CANONICAL_REPOSITORY, + validateImmutableRelease, + validateReleaseCheckout, +} from "./validate-release-checkout.mjs"; + +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sales-release-checkout-")); + +function git(args) { + const result = spawnSync("git", ["-C", temporaryRoot, ...args], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result.stdout.trim(); +} + +function rejects(message, callback) { + assert.throws(callback, undefined, message); +} + +try { + git(["init", "-b", "main"]); + git(["config", "user.name", "Release Test"]); + git(["config", "user.email", "release-test@example.invalid"]); + fs.writeFileSync(path.join(temporaryRoot, "fixture.txt"), "clean\n"); + git(["add", "fixture.txt"]); + git(["commit", "-m", "test fixture"]); + git(["remote", "add", "origin", `${CANONICAL_REPOSITORY}.git`]); + git(["tag", "v0.10.1"]); + const head = git(["rev-parse", "HEAD"]); + + const report = validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: "v0.10.1", + expectedCommit: head, + verifyRemote: false, + }); + assert.equal(report.repository, CANONICAL_REPOSITORY); + assert.equal(report.tag, "v0.10.1"); + assert.equal(report.commit, head); + + fs.writeFileSync(path.join(temporaryRoot, "fixture.txt"), "dirty\n"); + rejects("dirty checkout 必须失败", () => validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: "v0.10.1", + verifyRemote: false, + })); + fs.writeFileSync(path.join(temporaryRoot, "fixture.txt"), "clean\n"); + + git(["remote", "set-url", "origin", "https://github.com/wrong-owner/sales-intelligence-workbench.git"]); + rejects("错误 origin 必须失败", () => validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: "v0.10.1", + verifyRemote: false, + })); + git(["remote", "set-url", "origin", `${CANONICAL_REPOSITORY}.git`]); + + rejects("错误 tag 必须失败", () => validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: "v9.9.9", + verifyRemote: false, + })); + rejects("错误 commit 必须失败", () => validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: "v0.10.1", + expectedCommit: "0".repeat(40), + verifyRemote: false, + })); + + fs.writeFileSync(path.join(temporaryRoot, ".gitignore"), "ignored-artifact.txt\n"); + git(["add", ".gitignore"]); + git(["commit", "-m", "ignore fixture artifact"]); + git(["tag", "-f", "v0.10.1"]); + fs.writeFileSync(path.join(temporaryRoot, "ignored-artifact.txt"), "must still fail\n"); + rejects("被忽略的额外文件必须失败", () => validateReleaseCheckout({ + root: temporaryRoot, + expectedTag: "v0.10.1", + verifyRemote: false, + })); + + const immutableRelease = { + tag_name: "v0.10.1", + target_commitish: head, + immutable: true, + draft: false, + prerelease: false, + }; + const response = (body) => async () => ({ status: 200, json: async () => body }); + await validateImmutableRelease({ tag: "v0.10.1", commit: head }, response(immutableRelease)); + await assert.rejects( + validateImmutableRelease( + { tag: "v0.10.1", commit: head }, + response({ ...immutableRelease, immutable: false }), + ), + /immutable/, + ); + await assert.rejects( + validateImmutableRelease( + { tag: "v0.10.1", commit: head }, + response({ ...immutableRelease, target_commitish: "0".repeat(40) }), + ), + /commit/, + ); + + process.stdout.write("发行 checkout 校验器测试通过:正确来源与 immutable Release 放行,错误 origin、tag、commit、可变 Release、脏工作区和 ignored 残留全部拒绝。\n"); +} finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +} diff --git a/demohouse/sales-intelligence-workbench/scripts/test-skill-installer.mjs b/demohouse/sales-intelligence-workbench/scripts/test-skill-installer.mjs index 8422dbf3..6cc06a38 100644 --- a/demohouse/sales-intelligence-workbench/scripts/test-skill-installer.mjs +++ b/demohouse/sales-intelligence-workbench/scripts/test-skill-installer.mjs @@ -3,13 +3,23 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const commandPrinter = path.join(root, "scripts", "print-public-skill-command.mjs"); const temporaryHome = fs.mkdtempSync(path.join(os.tmpdir(), "sales-workbench-skill-")); const codexHome = path.join(temporaryHome, "codex"); const claudeConfigDir = path.join(temporaryHome, "claude"); +process.on("uncaughtExceptionMonitor", (error) => { + if (process.env.GITHUB_ACTIONS !== "true") return; + const message = String(error?.message || error || "unknown failure") + .split(temporaryHome).join("") + .split(root).join("") + .replace(/%/g, "%25") + .replace(/\r/g, "%0D") + .replace(/\n/g, "%0A"); + process.stderr.write(`::error title=Skill installer isolation failure::${message}\n`); +}); const baseEnvironment = { ...process.env, HOME: temporaryHome, @@ -48,17 +58,91 @@ function run(client, args, expectedStatus) { return result; } +function outputTail(value, limit = 3_500) { + const text = String(value || ""); + return text.length <= limit ? text : `[前置输出已省略]\n${text.slice(-limit)}`; +} + function assertInstalled(client) { const installedSkillPath = path.join(client.target, "SKILL.md"); assert.ok(fs.existsSync(installedSkillPath)); assert.ok(fs.existsSync(path.join(client.target, "scripts", "onboard.mjs"))); assert.ok(fs.existsSync(path.join(client.target, "assets", "app", "backend", ".env.example"))); + assert.ok(fs.existsSync(path.join(client.target, "assets", "app", "backend", "package.json"))); + assert.ok(fs.existsSync(path.join(client.target, "assets", "app", "backend", "src", "server.js"))); + assert.ok(fs.existsSync(path.join(client.target, "assets", "app", "frontend", "index.html"))); + assert.ok(fs.existsSync(path.join(client.target, "assets", "app", "frontend", "app.js"))); + assert.ok(fs.existsSync(path.join(client.target, "assets", "app", "supabase", "migrations"))); assert.equal(fs.existsSync(path.join(client.target, ".DS_Store")), false); const installedSkill = fs.readFileSync(installedSkillPath, "utf8"); assert.match(installedSkill, /## 远程 Skill 入口/); - assert.match(installedSkill, /skills\/sales-intelligence-workbench\/SKILL\.md/); - assert.match(installedSkill, /node scripts\/validate-skill-package\.mjs/); - assert.match(installedSkill, /node scripts\/test-skill-installer\.mjs/); + assert.match(installedSkill, /volcengine\/ai-app-lab\/blob\/main\/demohouse\/sales-intelligence-workbench\/skills\/sales-intelligence-workbench\/SKILL\.md/); + assert.match(installedSkill, /固定发行版本:v0\.10\.1/); + assert.match(installedSkill, /3494036618-eng\/sales-intelligence-workbench/); + assert.match(installedSkill, /不能下载 AI App Lab 的整个 monorepo/); + assert.match(installedSkill, /release_root="\$\(mktemp -d/); + assert.match(installedSkill, /\$releaseRoot = Join-Path/); + assert.match(installedSkill, /\$LASTEXITCODE -ne 0/); + assert.match(installedSkill, /node \(Join-Path \$releaseRoot "scripts\/validate-release-checkout\.mjs"\)/); + assert.match(installedSkill, /Remove-Item -LiteralPath \$releaseRoot -Recurse -Force/); + assert.match(installedSkill, /git clone --depth 1 --branch v0\.10\.1 --single-branch/); + assert.match(installedSkill, /validate-release-checkout\.mjs/); + assert.match(installedSkill, /不得复用、修改、清理或覆盖任何已有源码目录/); + assert.match(installedSkill, /完整的 `npm run verify`/); + assert.doesNotMatch(installedSkill, //); + assert.match(installedSkill, /完整的 `npm run verify`/); +} + +function probeInstalledSource(client, environment) { + const installedLibUrl = pathToFileURL(path.join(client.target, "scripts", "lib.mjs")).href; + const probe = spawnSync(process.execPath, [ + "--input-type=module", + "--eval", + ` + import fs from "node:fs"; + import path from "node:path"; + import { paths } from ${JSON.stringify(installedLibUrl)}; + const required = [ + "backend/package.json", + "backend/src/server.js", + "frontend/index.html", + "frontend/app.js", + "supabase/migrations", + ]; + process.stdout.write(JSON.stringify({ + skillRoot: paths.skillRoot, + sourceApp: paths.sourceApp, + required: Object.fromEntries(required.map((relative) => [ + relative, + fs.existsSync(path.join(paths.sourceApp, relative)), + ])), + })); + `, + ], { + env: environment, + encoding: "utf8", + }); + assert.equal( + probe.status, + 0, + `已安装 Skill 源目录探针执行失败。\nstdout:\n${probe.stdout}\nstderr:\n${probe.stderr}`, + ); + const report = JSON.parse(probe.stdout); + assert.equal( + fs.realpathSync(report.skillRoot), + fs.realpathSync(client.target), + `子进程 Skill 根目录解析错误:${probe.stdout}`, + ); + assert.equal( + fs.realpathSync(report.sourceApp), + fs.realpathSync(path.join(client.target, "assets", "app")), + `子进程应用源目录解析错误:${probe.stdout}`, + ); + assert.deepEqual( + Object.values(report.required), + [true, true, true, true, true], + `子进程无法读取完整应用包:${probe.stdout}`, + ); } try { @@ -115,6 +199,7 @@ try { SALES_WORKBENCH_STATE_HOME: path.join(temporaryHome, "state"), }; const codexTarget = clients[0].target; + probeInstalledSource(clients[0], onboardingEnvironment); const help = spawnSync(process.execPath, [ path.join(codexTarget, "scripts", "onboard.mjs"), "--help", @@ -139,7 +224,7 @@ try { assert.equal( onboarding.status, 0, - `onboarding 退出码异常。\nstdout:\n${onboarding.stdout}\nstderr:\n${onboarding.stderr}`, + `onboarding 退出码异常。\nstdout:\n${outputTail(onboarding.stdout)}\nstderr:\n${outputTail(onboarding.stderr)}`, ); assert.match(onboarding.stdout, /当前阶段:app/); assert.match(onboarding.stdout, /已安全暂停在“agent_plan”阶段/); @@ -150,8 +235,8 @@ try { const publicCommand = spawnSync(process.execPath, [ commandPrinter, - "--repository", "https://github.com/example/sales-workbench", - "--ref", "v0.9.1", + "--repository", "https://github.com/3494036618-eng/sales-intelligence-workbench", + "--ref", "v0.10.1", ], { cwd: root, env: baseEnvironment, @@ -160,14 +245,43 @@ try { assert.equal(publicCommand.status, 0, publicCommand.stderr); assert.equal( publicCommand.stdout.trim(), - "帮我初始化销售助手:https://github.com/example/sales-workbench/blob/v0.9.1/skills/sales-intelligence-workbench/SKILL.md", + "帮我初始化销售助手:https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v0.10.1/skills/sales-intelligence-workbench/SKILL.md", ); assert.doesNotMatch(publicCommand.stdout, /sales-assistant-builder\.md/); + const officialPublicCommand = spawnSync(process.execPath, [ + commandPrinter, + "--official", + ], { + cwd: root, + env: baseEnvironment, + encoding: "utf8", + }); + assert.equal(officialPublicCommand.status, 0, officialPublicCommand.stderr); + assert.equal( + officialPublicCommand.stdout.trim(), + "帮我初始化销售助手:https://github.com/volcengine/ai-app-lab/blob/main/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md", + ); + + const officialCommit = "0123456789abcdef0123456789abcdef01234567"; + const officialPinnedCommand = spawnSync(process.execPath, [ + commandPrinter, + "--official-ref", officialCommit, + ], { + cwd: root, + env: baseEnvironment, + encoding: "utf8", + }); + assert.equal(officialPinnedCommand.status, 0, officialPinnedCommand.stderr); + assert.equal( + officialPinnedCommand.stdout.trim(), + `帮我初始化销售助手:https://github.com/volcengine/ai-app-lab/blob/${officialCommit}/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md`, + ); + const nestedPublicCommand = spawnSync(process.execPath, [ commandPrinter, "--repository", "https://github.com/volcengine/ai-app-lab", - "--ref", "0123456789abcdef", + "--ref", officialCommit, "--skill-path", "demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md", ], { cwd: root, @@ -177,12 +291,12 @@ try { assert.equal(nestedPublicCommand.status, 0, nestedPublicCommand.stderr); assert.equal( nestedPublicCommand.stdout.trim(), - "帮我初始化销售助手:https://github.com/volcengine/ai-app-lab/blob/0123456789abcdef/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md", + `帮我初始化销售助手:https://github.com/volcengine/ai-app-lab/blob/${officialCommit}/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md`, ); const mutableCommand = spawnSync(process.execPath, [ commandPrinter, - "--repository", "https://github.com/example/sales-workbench", + "--repository", "https://github.com/3494036618-eng/sales-intelligence-workbench", "--ref", "main", ], { cwd: root, @@ -190,7 +304,54 @@ try { encoding: "utf8", }); assert.equal(mutableCommand.status, 1); - assert.match(mutableCommand.stderr, /不能使用 main\/master/); + assert.match(mutableCommand.stderr, /vX\.Y\.Z release tag 或完整 40 位 commit SHA/); + + for (const [label, args, errorPattern] of [ + ["wrong repository", [ + "--repository", "https://github.com/wrong-owner/sales-intelligence-workbench", + "--ref", "v0.10.1", + ], /不属于当前固定发行链路/], + ["wrong Skill path", [ + "--repository", "https://github.com/3494036618-eng/sales-intelligence-workbench", + "--ref", "v0.10.1", + "--skill-path", "skills/not-the-sales-skill/SKILL.md", + ], /不属于当前固定发行链路/], + ["wrong release version", [ + "--repository", "https://github.com/3494036618-eng/sales-intelligence-workbench", + "--ref", "v0.10.0", + ], /不属于当前固定发行链路/], + ["HEAD", [ + "--repository", "https://github.com/3494036618-eng/sales-intelligence-workbench", + "--ref", "HEAD", + ], /vX\.Y\.Z release tag 或完整 40 位 commit SHA/], + ["refs heads main", [ + "--repository", "https://github.com/3494036618-eng/sales-intelligence-workbench", + "--ref", "refs\/heads\/main", + ], /vX\.Y\.Z release tag 或完整 40 位 commit SHA/], + ["official conflict", ["--official", "--ref", "v0.10.1"], /不能与其他参数组合/], + ["short official SHA", ["--official-ref", "0123456789abcdef"], /完整 40 位 commit SHA/], + ]) { + const rejected = spawnSync(process.execPath, [commandPrinter, ...args], { + cwd: root, + env: baseEnvironment, + encoding: "utf8", + }); + assert.equal(rejected.status, 1, `${label} 应被拒绝。`); + assert.match(rejected.stderr, errorPattern, `${label} 错误信息不明确。`); + assert.doesNotMatch(rejected.stderr, /\n\s+at |file:\/\/|\/Users\//, `${label} 不应泄露堆栈或本机路径。`); + } + + const customCommand = spawnSync(process.execPath, [ + commandPrinter, + "--repository", "https://github.com/example/sales-workbench", + "--ref", "v1.2.3", + "--allow-custom-repository", + ], { + cwd: root, + env: baseEnvironment, + encoding: "utf8", + }); + assert.equal(customCommand.status, 0, customCommand.stderr); process.stdout.write( "Codex 与 Claude Code Skill 隔离安装、双端安装、重复安装保护、强制更新和安全 onboarding 检查通过。\n", diff --git a/demohouse/sales-intelligence-workbench/scripts/validate-public-release.mjs b/demohouse/sales-intelligence-workbench/scripts/validate-public-release.mjs index 1f914cf3..5b194fbd 100644 --- a/demohouse/sales-intelligence-workbench/scripts/validate-public-release.mjs +++ b/demohouse/sales-intelligence-workbench/scripts/validate-public-release.mjs @@ -33,6 +33,9 @@ const requiredPaths = [ "docs/deployment/self-hosting.md", "package-lock.json", "package.json", + "scripts/test-release-checkout.mjs", + "scripts/validate-release-checkout.mjs", + "scripts/verify-public-install.mjs", "skills/sales-intelligence-workbench/SKILL.md", ]; const forbiddenFilePatterns = [ @@ -47,7 +50,7 @@ const publicContentRules = [ { id: "clipboard_artifact", pattern: /codex-clipboard/i }, { id: "unexpected_sales_repository", - pattern: /github\.com\/(?!3494036618-eng\/sales-intelligence-workbench(?:[\/\s`]|$)|volcengine\/ai-app-lab(?:[\/\s`]|$))[^/\s]+\/sales-intelligence-workbench/i, + pattern: /github\.com\/(?!3494036618-eng\/sales-intelligence-workbench(?:\.git)?(?:[\/\s`'"),;]|$)|volcengine\/ai-app-lab(?:[\/\s`'"),;]|$))[^/\s]+\/sales-intelligence-workbench/i, }, { id: "environment_specific_release_note", @@ -55,6 +58,8 @@ const publicContentRules = [ }, ]; const contentScanExclusions = new Set([ + "scripts/test-release-checkout.mjs", + "scripts/test-skill-installer.mjs", "scripts/validate-public-release.mjs", "scripts/validate-skill-package.mjs", ]); @@ -190,10 +195,13 @@ const frontendApp = fs.readFileSync(path.join(root, "frontend", "app.js"), "utf8 const frontendStyles = fs.readFileSync(path.join(root, "frontend", "styles.css"), "utf8"); const authService = fs.readFileSync(path.join(root, "backend", "src", "security", "authService.js"), "utf8"); const salesService = fs.readFileSync(path.join(root, "backend", "src", "services", "salesService.js"), "utf8"); -const canonicalSkillUrl = `https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v${packageJson.version}/skills/sales-intelligence-workbench/SKILL.md`; +const canonicalRepository = "https://github.com/3494036618-eng/sales-intelligence-workbench"; +const canonicalReleaseSkillUrl = `${canonicalRepository}/blob/v${packageJson.version}/skills/sales-intelligence-workbench/SKILL.md`; +const officialSkillUrl = "https://github.com/volcengine/ai-app-lab/blob/main/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md"; for (const [label, text] of [["README", readme], ["Skill", skill]]) { - if (!text.includes(canonicalSkillUrl)) issues.push(`${label} is missing the canonical release Skill URL`); + if (!text.includes(officialSkillUrl)) issues.push(`${label} is missing the official AI App Lab Skill URL`); + if (!text.includes(canonicalReleaseSkillUrl)) issues.push(`${label} is missing the fixed release Skill URL`); } for (const officialName of [ "专业数据集(DataPro)", diff --git a/demohouse/sales-intelligence-workbench/scripts/validate-release-checkout.mjs b/demohouse/sales-intelligence-workbench/scripts/validate-release-checkout.mjs new file mode 100644 index 00000000..85143e03 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/scripts/validate-release-checkout.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const CANONICAL_REPOSITORY = "https://github.com/3494036618-eng/sales-intelligence-workbench"; +const RELEASE_API = "https://api.github.com/repos/3494036618-eng/sales-intelligence-workbench/releases/tags"; + +function runGit(root, args) { + const result = spawnSync("git", ["-C", root, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + throw new Error(`Git checkout 校验失败:git ${args.join(" ")}。`); + } + return result.stdout.trim(); +} + +function resolveRemoteTagCommit(root, repository, tag) { + const output = runGit(root, [ + "ls-remote", + "--exit-code", + `${normalizeRepository(repository)}.git`, + `refs/tags/${tag}`, + `refs/tags/${tag}^{}`, + ]); + const rows = output.split(/\r?\n/).filter(Boolean).map((line) => line.split(/\s+/)); + const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`); + const direct = rows.find(([, ref]) => ref === `refs/tags/${tag}`); + const commit = (peeled || direct)?.[0] || ""; + assert.match(commit, /^[0-9a-f]{40}$/i, `远程 ${tag} 没有解析到完整 commit SHA。`); + return commit.toLowerCase(); +} + +function normalizeRepository(value) { + let url; + try { + url = new URL(String(value || "").trim()); + } catch { + throw new Error("origin 不是有效的公开 HTTPS GitHub 仓库地址。"); + } + if (url.protocol !== "https:" || url.hostname !== "github.com" || url.username || url.password) { + throw new Error("origin 必须是无凭证的公开 HTTPS GitHub 仓库地址。"); + } + const segments = url.pathname.replace(/\/+$/, "").replace(/\.git$/, "").split("/").filter(Boolean); + if (segments.length !== 2) throw new Error("origin 必须指向 GitHub 仓库根目录。"); + return `https://github.com/${segments[0]}/${segments[1]}`; +} + +function packageVersion(root) { + const packagePath = path.join(root, "package.json"); + assert.ok(fs.existsSync(packagePath), "checkout 根目录缺少 package.json。"); + const parsed = JSON.parse(fs.readFileSync(packagePath, "utf8")); + assert.match(String(parsed.version || ""), /^\d+\.\d+\.\d+$/, "package.json version 无效。"); + return parsed.version; +} + +export function validateReleaseCheckout({ + root = scriptRoot, + expectedRepository = CANONICAL_REPOSITORY, + expectedTag = `v${packageVersion(root)}`, + expectedCommit = "", + verifyRemote = true, +} = {}) { + const resolvedRoot = path.resolve(root); + if (expectedCommit && !/^[0-9a-f]{40}$/i.test(expectedCommit)) { + throw new Error("expected commit 必须是完整 40 位 SHA。"); + } + + const actualRepository = normalizeRepository(runGit(resolvedRoot, ["remote", "get-url", "origin"])); + assert.equal(actualRepository, normalizeRepository(expectedRepository), "origin 不是固定发行仓库。"); + + const exactTag = runGit(resolvedRoot, ["describe", "--tags", "--exact-match", "HEAD"]); + assert.equal(exactTag, expectedTag, `checkout 必须精确位于 ${expectedTag}。`); + + const head = runGit(resolvedRoot, ["rev-parse", "HEAD"]); + const tagCommit = runGit(resolvedRoot, ["rev-parse", `${expectedTag}^{commit}`]); + assert.equal(head, tagCommit, "tag 没有指向当前 checkout commit。"); + if (expectedCommit) assert.equal(head, expectedCommit.toLowerCase(), "checkout commit 与预期发行 commit 不一致。"); + if (verifyRemote) { + const remoteCommit = resolveRemoteTagCommit(resolvedRoot, actualRepository, expectedTag); + assert.equal(head, remoteCommit, "checkout commit 与远程发行 tag 不一致。"); + } + + const status = runGit(resolvedRoot, ["status", "--porcelain", "--untracked-files=all", "--ignored=matching"]); + assert.equal(status, "", "发行 checkout 存在未提交、未跟踪或被忽略的额外文件。"); + + return { repository: actualRepository, tag: exactTag, commit: head }; +} + +export async function validateImmutableRelease(report, fetchImpl = fetch) { + const response = await fetchImpl(`${RELEASE_API}/${encodeURIComponent(report.tag)}`, { + headers: { "user-agent": "sales-intelligence-workbench-checkout-validator" }, + signal: AbortSignal.timeout(20_000), + }); + assert.equal(response.status, 200, `GitHub Release 不可用:HTTP ${response.status}。`); + const release = await response.json(); + assert.equal(release.tag_name, report.tag, "GitHub Release tag 与 checkout 不一致。"); + assert.equal(release.draft, false, "GitHub Release 仍是草稿。"); + assert.equal(release.prerelease, false, "GitHub Release 不能是预发布版本。"); + assert.equal(release.immutable, true, "GitHub Release 必须启用 immutable,防止 tag 或资产被事后替换。"); + assert.equal(String(release.target_commitish || "").toLowerCase(), report.commit, "GitHub Release commit 与 checkout 不一致。"); +} + +function readOption(name) { + const index = process.argv.indexOf(name); + if (index < 0) return ""; + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} 缺少参数值。`); + return value.trim(); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const args = process.argv.slice(2); + if (args.length !== 0 && !(args.length === 2 && args[0] === "--expected-commit")) { + throw new Error("只允许无参数运行,或提供 --expected-commit <完整40位SHA>。"); + } + const report = validateReleaseCheckout({ expectedCommit: readOption("--expected-commit") }); + process.stdout.write(`发行 checkout 校验通过:${report.tag} ${report.commit}\n`); + } catch (error) { + process.stderr.write(`错误:${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/demohouse/sales-intelligence-workbench/scripts/validate-skill-package.mjs b/demohouse/sales-intelligence-workbench/scripts/validate-skill-package.mjs index b2ae48d7..6bd0aa00 100644 --- a/demohouse/sales-intelligence-workbench/scripts/validate-skill-package.mjs +++ b/demohouse/sales-intelligence-workbench/scripts/validate-skill-package.mjs @@ -39,6 +39,9 @@ for (const relativePath of [ "scripts/install-agent-skill.mjs", "scripts/install-codex-skill.mjs", "scripts/install-claude-code-skill.mjs", + "scripts/test-release-checkout.mjs", + "scripts/validate-release-checkout.mjs", + "scripts/verify-public-install.mjs", ]) { assert.ok(fs.existsSync(path.join(root, relativePath)), `缺少客户端安装器:${relativePath}`); } @@ -47,11 +50,14 @@ const skill = read("skills/sales-intelligence-workbench/SKILL.md"); const agent = read("skills/sales-intelligence-workbench/agents/openai.yaml"); const workflow = read("skills/sales-intelligence-workbench/references/cookbook-workflow.md"); const readme = read("README.md"); +const ciWorkflow = read(".github/workflows/ci.yml"); const packageJson = JSON.parse(read("package.json")); const canonicalRepository = "https://github.com/3494036618-eng/sales-intelligence-workbench"; -const canonicalSkillUrl = `${canonicalRepository}/blob/v${packageJson.version}/skills/sales-intelligence-workbench/SKILL.md`; +const canonicalReleaseRef = `v${packageJson.version}`; +const canonicalReleaseSkillUrl = `${canonicalRepository}/blob/${canonicalReleaseRef}/skills/sales-intelligence-workbench/SKILL.md`; +const officialSkillUrl = "https://github.com/volcengine/ai-app-lab/blob/main/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md"; -assert.match(skill, /^---\nname: sales-intelligence-workbench\n/m); +assert.match(skill, /^---\r?\nname: sales-intelligence-workbench\r?\n/m); assert.match(agent, /\$sales-intelligence-workbench/); assert.match(agent, /allow_implicit_invocation:\s*true/); assert.match(skill, /onboard\.mjs/); @@ -63,9 +69,25 @@ const publicSkillCommand = skill.match( ); assert.ok(publicSkillCommand, "主 Skill 必须包含不带占位符的 GitHub 初始化 URL"); assert.doesNotMatch(publicSkillCommand[1], /[<>]/); -assert.equal(publicSkillCommand[1], canonicalSkillUrl); -assert.match(skill, /node scripts\/validate-skill-package\.mjs/); -assert.match(skill, /node scripts\/test-skill-installer\.mjs/); +assert.equal(publicSkillCommand[1], officialSkillUrl); +assert.ok(skill.includes(`固定发行版本:${canonicalReleaseRef}`)); +assert.ok(skill.includes(`固定发行仓库:${canonicalRepository}`)); +assert.ok(skill.includes(`固定发行 Skill:${canonicalReleaseSkillUrl}`)); +assert.match(skill, /不能下载 AI App Lab 的整个 monorepo/); +assert.match(skill, /release_root="\$\(mktemp -d/); +assert.ok(skill.includes(`git clone --depth 1 --branch ${canonicalReleaseRef} --single-branch`)); +assert.ok(skill.includes(`${canonicalRepository}.git "$release_root"`)); +assert.match(skill, /\$releaseRoot = Join-Path/); +assert.match(skill, /\$LASTEXITCODE -ne 0/); +assert.match(skill, /node \(Join-Path \$releaseRoot "scripts\/validate-release-checkout\.mjs"\)/); +assert.match(skill, /Remove-Item -LiteralPath \$releaseRoot -Recurse -Force/); +assert.match(skill, /validate-release-checkout\.mjs/); +assert.match(skill, /远程 tag 当前指向/); +assert.match(skill, /不得复用、修改、清理或覆盖任何已有源码目录/); +assert.match(skill, /完整的 `npm run verify`/); +assert.doesNotMatch(skill, //); +assert.doesNotMatch(skill, /从用户提供的 Skill URL 解析同一个 GitHub 仓库/); +assert.match(skill, /完整的 `npm run verify`/); assert.match(skill, /Codex/); assert.match(skill, /Claude Code/); assert.match(skill, /skill:install:codex/); @@ -86,6 +108,32 @@ assert.doesNotMatch(skill + readme, /reset-password|忘记密码|找回密码| const install = read("skills/sales-intelligence-workbench/scripts/install.mjs"); assert.match(install, /AUTH_REFRESH_COOKIE_MAX_AGE === "2592000"/); assert.match(install, /AUTH_REFRESH_COOKIE_MAX_AGE: "31536000"/); +assert.match(install, /schema_version: 2/); +assert.match(install, /release_version: sourceIdentity\.version/); +assert.match(install, /finalStagedIdentity/); +assert.match(install, /应用包测试后的最终内容身份与发行源不一致/); +const runtimeLib = read("skills/sales-intelligence-workbench/scripts/lib.mjs"); +assert.match(runtimeLib, /export function installedAppIntegrity/); +assert.match(runtimeLib, /export function assertInstalledAppIntegrity/); +assert.match(runtimeLib, /return assertInstalledAppIntegrity\(\)/); +const statusScript = read("skills/sales-intelligence-workbench/scripts/status.mjs"); +assert.match(statusScript, /integrity/); +assert.match(statusScript, /!integrity\.ok\) process\.exitCode = 1/); +const setupScript = read("skills/sales-intelligence-workbench/scripts/setup.mjs"); +assert.match(setupScript, /const appIntegrity = installedAppIntegrity\(\)/); +assert.match(setupScript, /const installed = appIntegrity\.ok/); +const publicVerifier = read("scripts/verify-public-install.mjs"); +assert.match(publicVerifier, /merge-base", "--is-ancestor"/); +assert.match(publicVerifier, /trackedManifest\(officialCheckout, officialProjectPath\)/); +assert.match(publicVerifier, /validateImmutableRelease/); +assert.match(publicVerifier, /mode/); +const checkoutValidator = read("scripts/validate-release-checkout.mjs"); +assert.match(checkoutValidator, /ls-remote/); +assert.match(checkoutValidator, /--ignored=matching/); +assert.match(checkoutValidator, /export async function validateImmutableRelease/); +assert.match(checkoutValidator, /release\.immutable, true/); +assert.match(install, /source_tree_sha256: sourceIdentity\.sha256/); +assert.match(read("skills/sales-intelligence-workbench/scripts/lib.mjs"), /export function appSourceIdentity/); const stop = read("skills/sales-intelligence-workbench/scripts/stop.mjs"); assert.match(stop, /Date\.now\(\) \+ 35_000/); assert.doesNotMatch(stop, /SIGKILL/); @@ -110,7 +158,15 @@ assert.match(readme, /npm run skill:install:claude/); assert.match(readme, /\$sales-intelligence-workbench/); assert.match(readme, /\/sales-intelligence-workbench/); assert.match(readme, /npm run skill:command/); +assert.match(readme, /npm run skill:command -- --official/); +assert.match(readme, /npm run skill:command -- --official-ref/); +assert.match(readme, /npm run release:verify:public/); +assert.match(readme, /npm run release:verify:public -- --official-ref/); +assert.match(ciWorkflow, /ubuntu-latest, macos-latest, windows-latest/); +assert.match(ciWorkflow, /node-version: 20/); assert.match(readme, /skills\/sales-intelligence-workbench\/SKILL\.md/); +assert.ok(readme.includes(officialSkillUrl)); +assert.ok(readme.includes(`${canonicalRepository}/tree/${canonicalReleaseRef}`)); assert.match( readme, /帮我初始化销售助手:`https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/blob\/[A-Za-z0-9._-]+\/(?:[A-Za-z0-9_.-]+\/)*skills\/sales-intelligence-workbench\/SKILL\.md`/, @@ -144,7 +200,11 @@ assert.equal(packageJson.scripts?.["skill:install:claude"], "node scripts/instal assert.equal(packageJson.scripts?.["skill:install:all"], "node scripts/install-agent-skill.mjs --target all"); assert.equal(packageJson.scripts?.["skill:command"], "node scripts/print-public-skill-command.mjs"); assert.equal(packageJson.scripts?.["release:validate"], "node scripts/validate-public-release.mjs"); +assert.equal(packageJson.scripts?.["release:validate:checkout"], "node scripts/validate-release-checkout.mjs"); +assert.equal(packageJson.scripts?.["release:test:checkout"], "node scripts/test-release-checkout.mjs"); +assert.equal(packageJson.scripts?.["release:verify:public"], "node scripts/verify-public-install.mjs"); assert.match(packageJson.scripts?.verify || "", /release:validate/); +assert.match(packageJson.scripts?.verify || "", /release:test:checkout/); assert.match(packageJson.scripts?.verify || "", /skill:validate/); assert.match(packageJson.scripts?.verify || "", /skill:test/); assert.match(packageJson.scripts?.verify || "", /backend run release:verify/); diff --git a/demohouse/sales-intelligence-workbench/scripts/verify-public-install.mjs b/demohouse/sales-intelligence-workbench/scripts/verify-public-install.mjs new file mode 100644 index 00000000..0611e97d --- /dev/null +++ b/demohouse/sales-intelligence-workbench/scripts/verify-public-install.mjs @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateImmutableRelease } from "./validate-release-checkout.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); +const repository = "https://github.com/3494036618-eng/sales-intelligence-workbench"; +const tag = `v${packageJson.version}`; +const officialRepository = "volcengine/ai-app-lab"; +const officialProjectPath = "demohouse/sales-intelligence-workbench"; +const officialSkillPath = `${officialProjectPath}/skills/sales-intelligence-workbench/SKILL.md`; +const officialUpstreamPath = `${officialProjectPath}/UPSTREAM.json`; +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sales-public-install-")); +const checkout = path.join(temporaryRoot, "checkout"); +const officialCheckout = path.join(temporaryRoot, "ai-app-lab"); + +function option(name) { + const index = process.argv.indexOf(name); + if (index < 0) return ""; + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} 缺少参数值。`); + return value.trim(); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || root, + env: options.env || process.env, + encoding: "utf8", + stdio: options.capture === false ? "inherit" : ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + const detail = options.capture === false ? "" : `\n${result.stderr || result.stdout}`; + throw new Error(`${command} ${args.join(" ")} 失败,退出码 ${result.status}。${detail}`); + } + return options.capture === false ? "" : result.stdout.trim(); +} + +function npmInvocation(args) { + const cli = [ + process.env.npm_execpath, + path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), + ].find((candidate) => candidate && /\.(?:c?m?js)$/i.test(candidate) && fs.existsSync(candidate)); + if (cli) return { command: process.execPath, args: [cli, ...args] }; + if (process.platform === "win32") { + return { + command: process.env.ComSpec || "cmd.exe", + args: ["/d", "/s", "/c", ["npm", ...args].join(" ")], + }; + } + return { command: "npm", args }; +} + +function remoteTagCommit() { + const output = run("git", [ + "ls-remote", + "--exit-code", + `${repository}.git`, + `refs/tags/${tag}`, + `refs/tags/${tag}^{}`, + ]); + const rows = output.split(/\r?\n/).filter(Boolean).map((line) => line.split(/\s+/)); + const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`); + const direct = rows.find(([, ref]) => ref === `refs/tags/${tag}`); + const commit = (peeled || direct)?.[0] || ""; + assert.match(commit, /^[0-9a-f]{40}$/i, `远程 ${tag} 没有解析到完整 commit SHA。`); + return commit.toLowerCase(); +} + +async function fetchText(url) { + const response = await fetch(url, { + headers: { "user-agent": "sales-intelligence-workbench-release-verifier" }, + signal: AbortSignal.timeout(20_000), + }); + assert.equal(response.status, 200, `远程文件不可用:${url},HTTP ${response.status}。`); + return response.text(); +} + +function trackedManifest(repositoryRoot, prefix = "") { + const args = ["-C", repositoryRoot, "ls-files", "--stage", "-z"]; + if (prefix) args.push("--", prefix); + const tracked = run("git", args).split("\0").filter(Boolean).map((record) => { + const separator = record.indexOf("\t"); + assert.notEqual(separator, -1, `无法解析 Git index 记录:${record}`); + const [mode] = record.slice(0, separator).split(/\s+/); + assert.match(mode, /^100(?:644|755)$/, `发行镜像包含不支持的 Git mode:${mode}`); + return { mode, trackedPath: record.slice(separator + 1) }; + }); + return tracked + .map(({ mode, trackedPath }) => { + const relativePath = prefix ? path.relative(prefix, trackedPath).split(path.sep).join("/") : trackedPath; + return { mode, trackedPath, relativePath }; + }) + .filter(({ relativePath }) => relativePath !== "UPSTREAM.json") + .map(({ mode, trackedPath, relativePath }) => ({ + path: relativePath, + mode, + sha256: createHash("sha256") + .update(fs.readFileSync(path.join(repositoryRoot, trackedPath))) + .digest("hex"), + })) + .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0); +} + +async function verifyOfficialMirror(officialRef, releaseCommit) { + if (!officialRef) { + process.stdout.write("未提供 --official-ref:本轮只验证独立仓库发行包,不放行官方宣传。\n"); + return; + } + assert.match(officialRef, /^[0-9a-f]{40}$/i, "--official-ref 必须是完整 40 位 AI App Lab commit SHA。"); + const rawBase = `https://raw.githubusercontent.com/${officialRepository}/${officialRef}`; + const [officialSkill, upstreamText] = await Promise.all([ + fetchText(`${rawBase}/${officialSkillPath}`), + fetchText(`${rawBase}/${officialUpstreamPath}`), + ]); + const releaseSkill = fs.readFileSync(path.join(checkout, "skills", "sales-intelligence-workbench", "SKILL.md"), "utf8"); + assert.equal(officialSkill, releaseSkill, "AI App Lab Skill 与独立发行包不一致。"); + + const upstream = JSON.parse(upstreamText); + assert.equal(String(upstream.repository || "").replace(/\/+$/, ""), repository); + assert.equal(upstream.version, packageJson.version, "UPSTREAM.json 版本与发行包不一致。"); + assert.equal(String(upstream.commit || "").toLowerCase(), releaseCommit, "UPSTREAM.json commit 与发行 tag 不一致。"); + + run("git", [ + "clone", + "--filter=blob:none", + "--sparse", + "--no-checkout", + `https://github.com/${officialRepository}.git`, + officialCheckout, + ]); + run("git", ["-C", officialCheckout, "sparse-checkout", "set", officialProjectPath]); + run("git", ["-C", officialCheckout, "rev-parse", "--verify", `${officialRef}^{commit}`]); + run("git", ["-C", officialCheckout, "merge-base", "--is-ancestor", officialRef, "refs/remotes/origin/main"]); + run("git", ["-C", officialCheckout, "checkout", "--detach", officialRef]); + assert.deepEqual( + trackedManifest(officialCheckout, officialProjectPath), + trackedManifest(checkout), + "AI App Lab 中的完整销售工作台镜像与独立发行包不一致。", + ); + + const command = run(process.execPath, [ + path.join(checkout, "scripts", "print-public-skill-command.mjs"), + "--official-ref", + officialRef, + ]); + assert.equal( + command, + `帮我初始化销售助手:https://github.com/${officialRepository}/blob/${officialRef}/${officialSkillPath}`, + ); + process.stdout.write(`AI App Lab 固定 commit 完整镜像验证通过:${officialRef}\n`); +} + +try { + const args = process.argv.slice(2); + if (args.length !== 0 && !(args.length === 2 && args[0] === "--official-ref")) { + throw new Error("只允许无参数运行,或提供 --official-ref 。"); + } + const officialRef = option("--official-ref"); + if (officialRef && !/^[0-9a-f]{40}$/i.test(officialRef)) { + throw new Error("--official-ref 必须是完整 40 位 AI App Lab commit SHA。"); + } + const releaseCommit = remoteTagCommit(); + process.stdout.write(`远程 tag 已解析:${tag} ${releaseCommit}\n`); + await validateImmutableRelease({ tag, commit: releaseCommit }); + process.stdout.write(`GitHub immutable Release 验证通过:${tag} ${releaseCommit}\n`); + + run("git", [ + "clone", + "--depth", + "1", + "--branch", + tag, + "--single-branch", + `${repository}.git`, + checkout, + ]); + run(process.execPath, [ + path.join(checkout, "scripts", "validate-release-checkout.mjs"), + "--expected-commit", + releaseCommit, + ], { cwd: checkout, capture: false }); + const npmVerify = npmInvocation(["run", "verify"]); + run(npmVerify.command, npmVerify.args, { + cwd: checkout, + capture: false, + }); + await verifyOfficialMirror(officialRef, releaseCommit); + process.stdout.write(`公开安装验收通过:${repository}/tree/${tag}\n`); +} catch (error) { + process.stderr.write(`公开安装验收失败:${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md index fdbe5b6f..ac3eee12 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md @@ -10,32 +10,87 @@ description: 从 0 到 1 搭建、配置、验收和维护真实数据驱动的 ## 远程 Skill 入口 用户可能在 Codex 或 Claude Code 中直接通过公开的主 Skill URL 触发本流程,而不是预先克隆 -仓库、安装 Skill 或准备本机配置。两端使用同一份 Skill 和同一套业务逻辑。独立发行仓库 -使用以下版本化入口;其他发行位置也必须固定到已发布的 tag 或经过审核的 commit SHA: +仓库、安装 Skill 或准备本机配置。两端使用同一份 Skill 和同一套业务逻辑。火山方舟 +AI App Lab 中的官方入口为: ```text -帮我初始化销售助手:https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v0.10.0/skills/sales-intelligence-workbench/SKILL.md +帮我初始化销售助手:https://github.com/volcengine/ai-app-lab/blob/main/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/SKILL.md +``` + +该 URL 只负责让 Agent 找到本 Skill,不是应用包下载源。应用包必须固定从以下不可变发行版本 +获取,不得根据用户提供的入口 URL 改用其他仓库、分支或同名项目: + +```text +固定发行仓库:https://github.com/3494036618-eng/sales-intelligence-workbench +固定发行版本:v0.10.1 +固定发行 Skill:https://github.com/3494036618-eng/sales-intelligence-workbench/blob/v0.10.1/skills/sales-intelligence-workbench/SKILL.md ``` 如果当前环境中不存在 `{baseDir}/scripts/status.mjs`,说明本 Skill 是从远程 URL 打开的。 此时 Agent 必须: -1. 从用户提供的 Skill URL 解析同一个 GitHub 仓库和 ``,取得该版本的完整仓库,不能 - 只下载 `SKILL.md`,也不能通过搜索结果猜测同名仓库。 -2. 将 `{baseDir}` 设为仓库中的 `skills/sales-intelligence-workbench`,确认 +1. 无论用户从官方入口还是发行 Skill 打开本文件,都只取得上面指定的独立发行仓库 + `v0.10.1` 完整版本。先说明会把公开源码写入本机,然后在 macOS/Linux 的 POSIX shell + 中使用系统临时目录执行;变量始终加双引号,不让用户复制或替换路径占位符: + + ```bash + release_root="$(mktemp -d "${TMPDIR:-/tmp}/sales-intelligence-workbench-v0.10.1-XXXXXX")" + git clone --depth 1 --branch v0.10.1 --single-branch \ + https://github.com/3494036618-eng/sales-intelligence-workbench.git "$release_root" + ``` + + Windows PowerShell 使用以下等价命令;同样只新建唯一目录,并始终引用完整路径: + + ```powershell + $releaseRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("sales-intelligence-workbench-v0.10.1-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $releaseRoot -ErrorAction Stop | Out-Null + git clone --depth 1 --branch v0.10.1 --single-branch ` + https://github.com/3494036618-eng/sales-intelligence-workbench.git $releaseRoot + if ($LASTEXITCODE -ne 0) { + Remove-Item -LiteralPath $releaseRoot -Recurse -Force + throw "v0.10.1 clone 失败,已停止且不会换源或降级。" + } + ``` + + 不能下载 AI App Lab 的整个 monorepo,不能使用 `main`,不能只下载 `SKILL.md`,也不能 + 通过搜索结果猜测同名仓库。clone 失败时只删除本次新建的临时目录并停止,不能换源或降级。 +2. 在下载仓库中执行确定性来源校验;该脚本会同时核对 origin、精确 tag、本地 commit、 + 远程 tag 当前指向和不含 ignored 残留的干净工作区,任一不符都会返回非零: + + ```bash + node "$release_root/scripts/validate-release-checkout.mjs" + ``` + + Windows PowerShell 对应执行: + + ```powershell + node (Join-Path $releaseRoot "scripts/validate-release-checkout.mjs") + if ($LASTEXITCODE -ne 0) { throw "发行来源校验失败,停止安装。" } + ``` + + 脚本非零时停止,不继续安装。远程 URL 初始化只能使用本次新建并 clone 的 `release_root`; + 不得复用、修改、清理或覆盖任何已有源码目录。 +3. 将项目根目录设为 `release_root`,将 `{baseDir}` 设为其中的 + `skills/sales-intelligence-workbench`,确认 `{baseDir}/scripts/`、`{baseDir}/references/`、`{baseDir}/assets/app/` 及仓库根目录 `package.json` 均存在。 -3. 在仓库根目录执行 `node scripts/validate-skill-package.mjs` 和 - `node scripts/test-skill-installer.mjs`。两项都通过后按当前客户端安装: +4. 在仓库根目录执行完整的 `npm run verify`。公开包扫描、来源校验器测试、Skill 包校验、 + 双客户端隔离安装、后端测试、密钥扫描、内嵌应用一致性和 Skill 生命周期全部通过后, + 才能按当前客户端安装: - Codex:`npm run skill:install:codex` - Claude Code:`npm run skill:install:claude` - 用户明确要求两端都安装:`npm run skill:install:all` 已安装旧版时先说明影响,再为对应命令追加 `-- --force`。 -4. 立即使用刚取得仓库中的本文件继续阶段 0,不要求用户重启当前客户端,也不让用户重复 +5. 立即使用刚取得仓库中的本文件继续阶段 0,不要求用户重启当前客户端,也不让用户重复 提供源码目录。后续重新打开时,Codex 使用 `$sales-intelligence-workbench`,Claude Code - 使用 `/sales-intelligence-workbench`。 -5. 已有同名目录时先核对 Git remote、版本和工作区状态;不覆盖用户改动,不创建第二套 - 运行时。下载、校验和安装阶段不创建云资源、不调用 Agent Plan 外部能力、不产生 AFP。 + 使用 `/sales-intelligence-workbench`。当前初始化流程已切换到安装后的 Skill,或流程结束、 + 取消、失败且不再需要源码 checkout 时,删除本次创建的 `release_root`;不得删除用户明确 + 要求复用的已有目录。POSIX 使用 `rm -rf -- "$release_root"`,PowerShell 使用 + `Remove-Item -LiteralPath $releaseRoot -Recurse -Force`,删除前再次确认变量确实指向本次创建的 + `sales-intelligence-workbench-v0.10.1-` 临时目录。 +6. 已有同名 Skill 或运行时时先说明影响;没有 `--force` 不得覆盖 Skill,运行时仍按自身 + 停服、备份和原子替换规则处理,不创建第二套运行时。下载、校验和安装阶段不创建云资源、 + 不调用 Agent Plan 外部能力、不产生 AFP。 “什么也没配置”表示用户不需要预先准备本地项目、依赖或配置文件,不代表可以绕过云服务 账号、Agent Plan 套餐、AI Native 应用开发底座(Supabase)/Agent 记忆(OpenViking)权限、飞书登录或真实调用费用。用户侧只输入 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/package.json b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/package.json index 6991476b..d6d6df76 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/package.json +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/package.json @@ -1,11 +1,11 @@ { "name": "sales-intelligence-workbench-api", - "version": "0.10.0", + "version": "0.10.1", "private": true, "type": "module", "scripts": { "dev": "node src/server.js", - "test": "node --test tests/*.test.mjs", + "test": "node scripts/run-tests.mjs", "release:secrets": "node scripts/check-release-secrets.mjs", "release:verify": "node scripts/verify-release-local.mjs", "doctor": "node scripts/doctor.mjs", diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/export-workspace.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/export-workspace.mjs index d2bea44a..9d420aec 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/export-workspace.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/export-workspace.mjs @@ -55,8 +55,13 @@ function parseArgs(argv) { function assertPrivateSession(filePath) { const session = readAuthSession(filePath); if (!session) throw new Error("未找到 CLI 登录态。请先运行 Skill 的 login.mjs。"); - const mode = fs.statSync(filePath).mode & 0o077; - if (mode !== 0) throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + const status = fs.lstatSync(filePath); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error("CLI 会话路径必须是非符号链接的普通文件。"); + } + if (process.platform !== "win32" && (status.mode & 0o077) !== 0) { + throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + } return session; } diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/run-tests.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/run-tests.mjs new file mode 100644 index 00000000..35b38711 --- /dev/null +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/run-tests.mjs @@ -0,0 +1,49 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const backendDir = path.resolve(scriptDir, ".."); +const testsDir = path.join(backendDir, "tests"); +const testFiles = fs.readdirSync(testsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".test.mjs")) + .map((entry) => path.join("tests", entry.name)) + .sort((left, right) => left.localeCompare(right, "en")); + +if (!testFiles.length) throw new Error("未找到后端测试文件。"); + +const result = spawnSync(process.execPath, ["--test", ...testFiles], { + cwd: backendDir, + encoding: "utf8", + env: process.env, + maxBuffer: 4 * 1024 * 1024, +}); +if (result.error) throw result.error; +if (result.status === 0) { + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); +} else { + const lines = String(result.stdout || "").split(/\r?\n/); + const failureBlocks = []; + for (let index = 0; index < lines.length; index += 1) { + if (!/^not ok \d+ - /.test(lines[index])) continue; + const start = index > 0 && lines[index - 1].startsWith("# Subtest:") ? index - 1 : index; + let end = index + 1; + while (end < lines.length && !lines[end].startsWith("# Subtest:") && !/^1\.\./.test(lines[end])) { + end += 1; + } + failureBlocks.push(lines.slice(start, end).join("\n").trimEnd()); + index = end - 1; + } + const summary = lines.filter((line) => /^# (?:tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b/.test(line)); + process.stdout.write([ + "后端测试未通过,仅显示失败项与汇总:", + ...failureBlocks, + ...summary, + "", + ].join("\n")); + process.stderr.write(result.stderr); + process.exitCode = result.status || 1; +} diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-business-chain.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-business-chain.mjs index 6e08db44..01ff7af4 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-business-chain.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-business-chain.mjs @@ -202,8 +202,13 @@ function assertPrivateSession(filePath) { if (!session) { throw new Error("未找到有效 CLI 登录态。请先运行 Skill 的 login.mjs,密码不要发送到聊天或命令行参数。"); } - const mode = fs.statSync(filePath).mode & 0o077; - if (mode !== 0) throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + const status = fs.lstatSync(filePath); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error("CLI 会话路径必须是非符号链接的普通文件。"); + } + if (process.platform !== "win32" && (status.mode & 0o077) !== 0) { + throw new Error("CLI 会话文件权限不安全;请将其权限改为 0600 后重试。"); + } return session; } diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-release-local.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-release-local.mjs index 8b4f1e57..be9e3e7c 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-release-local.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/scripts/verify-release-local.mjs @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; @@ -6,7 +7,24 @@ import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const backendDir = path.resolve(scriptDir, ".."); const projectRoot = path.resolve(backendDir, ".."); -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +function npmInvocation(args) { + const cli = [ + process.env.npm_execpath, + path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), + ].find((candidate) => candidate && /\.(?:c?m?js)$/i.test(candidate) && fs.existsSync(candidate)); + if (cli) return { command: process.execPath, args: [cli, ...args] }; + if (process.platform === "win32") { + return { + command: process.env.ComSpec || "cmd.exe", + args: ["/d", "/s", "/c", ["npm", ...args].join(" ")], + }; + } + return { command: "npm", args }; +} + +const npmTest = npmInvocation(["test"]); +const npmSecretScan = npmInvocation(["run", "release:secrets"]); const steps = [ { @@ -23,14 +41,14 @@ const steps = [ }, { name: "后端自动化测试", - command: npmCommand, - args: ["test"], + command: npmTest.command, + args: npmTest.args, cwd: backendDir, }, { name: "发布密钥扫描", - command: npmCommand, - args: ["run", "release:secrets"], + command: npmSecretScan.command, + args: npmSecretScan.args, cwd: backendDir, }, { @@ -70,6 +88,15 @@ try { steps.forEach(runStep); console.log(`\n离线发布验收通过:${steps.length}/${steps.length} 项完成。`); } catch (error) { - console.error(`\n离线发布验收失败:${error?.message || String(error)}`); + const message = `离线发布验收失败:${error?.message || String(error)}`; + console.error(`\n${message}`); + if (process.env.GITHUB_ACTIONS === "true") { + const annotation = message + .split(projectRoot).join("") + .replace(/%/g, "%25") + .replace(/\r/g, "%0D") + .replace(/\n/g, "%0A"); + process.stderr.write(`::error title=Offline release verification failure::${annotation}\n`); + } process.exitCode = 1; } diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/providerConfig.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/providerConfig.js index 5c7cbadb..7b0ec1ac 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/providerConfig.js +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/config/providerConfig.js @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { delimiter, isAbsolute, join } from "node:path"; import { createEnvReader } from "./runtimeEnv.js"; import { createRuntimePolicy, publicRuntimePolicy } from "./runtimePolicy.js"; @@ -17,7 +17,7 @@ function unique(values) { function commandExists(command) { const value = String(command || "").trim(); if (!value) return false; - if (value.includes("/")) return existsSync(value); + if (isAbsolute(value) || value.includes("/") || value.includes("\\")) return existsSync(value); return String(process.env.PATH || "") .split(delimiter) .filter(Boolean) diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/openVikingProvider.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/openVikingProvider.js index 6409dd13..ce088625 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/openVikingProvider.js +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/providers/openVikingProvider.js @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { delimiter, isAbsolute, join } from "node:path"; import { promisify } from "node:util"; import { createEnvReader } from "../config/runtimeEnv.js"; import { providerFailure, providerSuccess } from "./providerResult.js"; @@ -48,7 +48,7 @@ function defaultCliConfigPath() { function commandExists(command) { const value = String(command || "").trim(); if (!value) return false; - if (value.includes("/")) return existsSync(value); + if (isAbsolute(value) || value.includes("/") || value.includes("\\")) return existsSync(value); return String(process.env.PATH || "") .split(delimiter) .filter(Boolean) diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/routes/index.js b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/routes/index.js index a9d23924..fc8ef95b 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/routes/index.js +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/src/routes/index.js @@ -79,7 +79,7 @@ export function createRouter(providerService, options = {}) { data: { status: runtimePolicy.ready ? "ok" : "degraded", service: "sales-intelligence-workbench-api", - version: "0.10.0", + version: "0.10.1", provider_mode: providerMode, runtime_ready: runtimePolicy.ready, }, diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportScript.test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportScript.test.mjs index bf68e82c..ef2ae5a9 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportScript.test.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/assets/app/backend/tests/feishuImportScript.test.mjs @@ -60,7 +60,10 @@ test("backend requests refresh an expired bearer session and rotate the private const rotated = JSON.parse(fs.readFileSync(sessionFile, "utf8")); assert.equal(rotated.access_token, "fresh-access"); assert.equal(rotated.refresh_token, "rotated-refresh"); - assert.equal(fs.statSync(sessionFile).mode & 0o077, 0); + const sessionStatus = fs.lstatSync(sessionFile); + assert.equal(sessionStatus.isFile(), true); + assert.equal(sessionStatus.isSymbolicLink(), false); + if (process.platform !== "win32") assert.equal(sessionStatus.mode & 0o077, 0); } finally { globalThis.fetch = originalFetch; fs.rmSync(directory, { recursive: true, force: true }); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/security.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/security.md index ace90c88..a53eb068 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/security.md +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/security.md @@ -6,6 +6,7 @@ - 前端、仓库、日志、截图、测试夹具和 Provider Run 不得出现完整密钥。 - Skill 应用包同步时排除 `.env.local`、依赖、临时目录、日志、PID 和备份。 - 用户曾在聊天或公开文档粘贴的密钥应视为暴露并轮换。 +- `0600`/`0700` 适用于 macOS/Linux。Windows 下私密文件继承当前用户配置目录的 NTFS ACL,并且必须是非符号链接的普通文件。 ## 数据 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/setup.md b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/setup.md index e4f9d4ab..62fabd4a 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/setup.md +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/references/setup.md @@ -15,7 +15,7 @@ | 私密配置 | `~/.config/sales-intelligence-workbench` | | 日志、PID、doctor 证据和备份 | `~/.local/state/sales-intelligence-workbench` | -配置文件和状态目录权限为 `0700`,凭证文件为 `0600`。源码目录、运行目录和配置目录必须分开。 +在 macOS/Linux 下,配置文件和状态目录权限为 `0700`,凭证文件为 `0600`。Windows 下它们继承当前用户目录的 NTFS ACL,凭证和会话路径必须是非符号链接的普通文件。源码目录、运行目录和配置目录必须分开。 ## 首次部署顺序 diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/install.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/install.mjs index 78825ef8..f14118f3 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/install.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/install.mjs @@ -3,6 +3,7 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { appCopyFilter, + appSourceIdentity, assertAppSource, assertNodeVersion, ensureDirectories, @@ -18,6 +19,21 @@ import { writeConfiguration, } from "./lib.mjs"; +function npmInvocation(args) { + const cli = [ + process.env.npm_execpath, + path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), + ].find((candidate) => candidate && /\.(?:c?m?js)$/i.test(candidate) && fs.existsSync(candidate)); + if (cli) return { command: process.execPath, args: [cli, ...args] }; + if (process.platform === "win32") { + return { + command: process.env.ComSpec || "cmd.exe", + args: ["/d", "/s", "/c", ["npm", ...args].join(" ")], + }; + } + return { command: "npm", args }; +} + assertNodeVersion(); ensureDirectories(); @@ -30,6 +46,7 @@ if (await waitForHealth(serverAddress().url, 800)) { const sourceValue = readOption("--source"); const sourceRoot = assertAppSource(sourceValue ? resolveUserPath(sourceValue) : paths.sourceApp); +const sourceIdentity = appSourceIdentity(sourceRoot); if (path.resolve(sourceRoot) === path.resolve(paths.installedApp)) { throw new Error("运行时安装目录不能同时作为源码目录。"); } @@ -44,19 +61,40 @@ try { force: true, filter: (entry) => appCopyFilter(sourceRoot, entry), }); - assertAppSource(staging); + try { + assertAppSource(staging); + } catch (error) { + throw new Error(`应用包复制后不完整:${error instanceof Error ? error.message : String(error)}`); + } + const stagedIdentity = appSourceIdentity(staging); + if (stagedIdentity.sha256 !== sourceIdentity.sha256) { + throw new Error("应用包复制后的内容哈希与发行源不一致。"); + } if (!skipTests) { run(process.execPath, ["--check", "frontend/app.js"], { cwd: staging }); run(process.execPath, ["--check", "frontend/text-format.js"], { cwd: staging }); - run(process.platform === "win32" ? "npm.cmd" : "npm", ["test"], { + const npmTest = npmInvocation(["test"]); + run(npmTest.command, npmTest.args, { cwd: path.join(staging, "backend"), env: { ...process.env, NODE_ENV: "test" }, }); } + const finalStagedIdentity = appSourceIdentity(staging); + if ( + finalStagedIdentity.version !== sourceIdentity.version + || finalStagedIdentity.sha256 !== sourceIdentity.sha256 + || finalStagedIdentity.file_count !== sourceIdentity.file_count + ) { + throw new Error("应用包测试后的最终内容身份与发行源不一致。"); + } + fs.writeFileSync(path.join(staging, ".sales-workbench-runtime.json"), `${JSON.stringify({ - schema_version: 1, + schema_version: 2, + release_version: sourceIdentity.version, + source_tree_sha256: sourceIdentity.sha256, + source_file_count: sourceIdentity.file_count, source_path: sourceRoot, installed_at: new Date().toISOString(), }, null, 2)}\n`, { mode: 0o600 }); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/lib.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/lib.mjs index 5b59a97d..cd3abafd 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/lib.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/lib.mjs @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; @@ -373,12 +373,54 @@ export function assertAppSource(sourcePath) { } export function assertInstalledApp() { - return assertAppSource(paths.installedApp); + return assertInstalledAppIntegrity(); +} + +export function installedAppIntegrity() { + if (!fs.existsSync(paths.installedApp)) return { ok: false, reason: "not_installed" }; + const metadataPath = path.join(paths.installedApp, ".sales-workbench-runtime.json"); + if (!fs.existsSync(metadataPath)) return { ok: false, reason: "missing_release_metadata" }; + try { + const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8")); + const actual = appSourceIdentity(paths.installedApp); + const ok = metadata.schema_version === 2 + && metadata.release_version === actual.version + && metadata.source_tree_sha256 === actual.sha256 + && metadata.source_file_count === actual.file_count; + return { + ok, + reason: ok ? null : "release_identity_mismatch", + release_version: metadata.release_version || null, + actual_version: actual.version, + expected_tree_sha256: metadata.source_tree_sha256 || null, + actual_tree_sha256: actual.sha256, + expected_file_count: metadata.source_file_count ?? null, + actual_file_count: actual.file_count, + }; + } catch { + return { ok: false, reason: "invalid_release_metadata" }; + } +} + +export function assertInstalledAppIntegrity() { + const report = installedAppIntegrity(); + if (!report.ok) { + throw new Error(`已安装应用未通过发行完整性校验(${report.reason});请重新执行 install.mjs。`); + } + return paths.installedApp; } export function appCopyFilter(rootDir, sourcePath) { - const relative = path.relative(rootDir, sourcePath); + const canonical = (value) => { + try { + return fs.realpathSync.native(value); + } catch { + return path.resolve(value); + } + }; + const relative = path.relative(canonical(rootDir), canonical(sourcePath)); if (!relative) return true; + if (relative.startsWith("..") || path.isAbsolute(relative)) return false; const segments = relative.split(path.sep); const first = segments[0]; if (!["backend", "frontend", "supabase"].includes(first)) return false; @@ -389,6 +431,37 @@ export function appCopyFilter(rootDir, sourcePath) { return !/\.(?:log|pid)$/i.test(name); } +export function appSourceIdentity(sourcePath) { + const rootDir = assertAppSource(sourcePath); + const files = []; + const visit = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (!appCopyFilter(rootDir, fullPath)) continue; + if (entry.isSymbolicLink()) throw new Error("应用包不能包含符号链接。"); + if (entry.isDirectory()) visit(fullPath); + else if (entry.isFile()) files.push(fullPath); + } + }; + visit(rootDir); + files.sort((left, right) => path.relative(rootDir, left).localeCompare(path.relative(rootDir, right), "en")); + + const hash = createHash("sha256"); + for (const filePath of files) { + const relativePath = path.relative(rootDir, filePath).split(path.sep).join("/"); + hash.update(relativePath); + hash.update("\0"); + hash.update(fs.readFileSync(filePath)); + hash.update("\0"); + } + const backendPackage = JSON.parse(fs.readFileSync(path.join(rootDir, "backend", "package.json"), "utf8")); + return { + version: String(backendPackage.version || ""), + sha256: hash.digest("hex"), + file_count: files.length, + }; +} + export function readOption(name, args = process.argv.slice(2)) { const index = args.indexOf(name); if (index < 0) return null; @@ -503,7 +576,9 @@ export async function waitForHealth(url, timeoutMs = 20_000) { export function credentialFileIsPrivate() { try { - return (fs.statSync(paths.credentialsFile).mode & 0o077) === 0; + const status = fs.lstatSync(paths.credentialsFile); + if (!status.isFile() || status.isSymbolicLink()) return false; + return process.platform === "win32" || (status.mode & 0o077) === 0; } catch { return false; } diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/self-test.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/self-test.mjs index 2a77694c..d83d7211 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/self-test.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/self-test.mjs @@ -89,6 +89,34 @@ if (command === "list") { assert.equal(initialReport.stages.find((item) => item.id === "app")?.status, "pending"); runScript("install.mjs"); + const installedMetadata = JSON.parse(fs.readFileSync( + path.join(isolatedEnv.SALES_WORKBENCH_HOME, "app", ".sales-workbench-runtime.json"), + "utf8", + )); + const installedPackage = JSON.parse(fs.readFileSync( + path.join(isolatedEnv.SALES_WORKBENCH_HOME, "app", "backend", "package.json"), + "utf8", + )); + assert.equal(installedMetadata.schema_version, 2); + assert.equal(installedMetadata.release_version, installedPackage.version); + assert.match(installedMetadata.source_tree_sha256, /^[0-9a-f]{64}$/); + assert.ok(installedMetadata.source_file_count > 0); + let statusReport = JSON.parse(runScript("status.mjs").stdout); + assert.equal(statusReport.integrity.ok, true); + const installedFrontend = path.join(isolatedEnv.SALES_WORKBENCH_HOME, "app", "frontend", "index.html"); + const originalFrontend = fs.readFileSync(installedFrontend); + fs.appendFileSync(installedFrontend, "\n\n"); + const tamperedStatus = runScript("status.mjs", [], { expectSuccess: false }); + assert.notEqual(tamperedStatus.status, 0); + statusReport = JSON.parse(tamperedStatus.stdout); + assert.equal(statusReport.integrity.ok, false); + const tamperedSetup = JSON.parse(runScript("setup.mjs", ["--json"]).stdout); + assert.equal(tamperedSetup.stages.find((item) => item.id === "app")?.status, "pending"); + assert.notEqual(runScript("doctor.mjs", [], { expectSuccess: false }).status, 0); + assert.notEqual(runScript("start.mjs", ["--dry-run"], { expectSuccess: false }).status, 0); + fs.writeFileSync(installedFrontend, originalFrontend); + statusReport = JSON.parse(runScript("status.mjs").stdout); + assert.equal(statusReport.integrity.ok, true); runScript("configure.mjs", ["--from-env-file", fixtureEnv]); const configureSource = fs.readFileSync(path.join(scriptsDir, "configure.mjs"), "utf8"); assert.doesNotMatch(configureSource, /OpenViking 数据面 API Key|OpenViking 专用 API Key/); @@ -113,7 +141,10 @@ if (command === "list") { const credentialsPath = path.join(isolatedEnv.SALES_WORKBENCH_CONFIG_HOME, "credentials.env"); const credentialsConfig = fs.readFileSync(credentialsPath, "utf8"); assert.match(credentialsConfig, new RegExp(fakeOpenVikingApiKey)); - assert.equal(fs.statSync(credentialsPath).mode & 0o777, 0o600); + const credentialsStatus = fs.lstatSync(credentialsPath); + assert.equal(credentialsStatus.isFile(), true); + assert.equal(credentialsStatus.isSymbolicLink(), false); + if (process.platform !== "win32") assert.equal(credentialsStatus.mode & 0o777, 0o600); runtimeConfig = fs.readFileSync(path.join(isolatedEnv.SALES_WORKBENCH_CONFIG_HOME, "runtime.env"), "utf8"); assert.match(runtimeConfig, /OPENVIKING_RESOURCE_ID="ov-self-test"/); assert.match(runtimeConfig, /OPENVIKING_COLLECTION_NAME="sales_memory"/); diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-openviking.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-openviking.mjs index 6b924926..30a83f2c 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-openviking.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup-openviking.mjs @@ -109,7 +109,12 @@ function parseJsonOutput(stdout, label) { function controlPlaneCommand() { const override = String(process.env.OPENVIKING_CONTROL_PLANE_CLI || "").trim(); - if (override) return { command: override, prefix: [] }; + if (override) { + if (/\.(?:c?m?js)$/i.test(override)) { + return { command: process.execPath, prefix: [override] }; + } + return { command: override, prefix: [] }; + } if (commandExists("ov-cp")) return { command: "ov-cp", prefix: [] }; if (commandExists("uvx")) { return { diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup.mjs index fcb161fd..535c6f11 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/setup.mjs @@ -4,6 +4,7 @@ import { commandExists, configurationSummary, ensureDirectories, + installedAppIntegrity, liveDoctorEvidence, paths, processExists, @@ -195,7 +196,8 @@ async function buildReport() { const doctor = liveDoctorEvidence(); const importReceipt = readJson(paths.historyImportReceiptFile); const acceptance = readJson(paths.businessAcceptanceFile); - const installed = fs.existsSync(paths.installedApp); + const appIntegrity = installedAppIntegrity(); + const installed = appIntegrity.ok; const serverRunning = processExists(readPid()); const workerRunning = processExists(readPid(paths.workerPidFile)); const address = serverAddress(); @@ -209,7 +211,9 @@ async function buildReport() { stage("brief", "确认销售场景", brief?.workspace_name && brief?.sales_goal ? "complete" : "pending", brief ? `${brief.workspace_name};${brief.sales_goal}` : "尚未记录业务范围"), stage("app", "安装完整应用", installed ? "complete" : "pending", - installed ? paths.installedApp : "尚未安装前后端运行时"), + installed ? paths.installedApp : appIntegrity.reason === "not_installed" + ? "尚未安装前后端运行时" + : `已安装目录未通过发行完整性校验:${appIntegrity.reason}`), stage("agent_plan", "配置 Agent Plan 模型与能力卡片", planKeyReady && summary.model && summary.datapro && summary.web_search ? "complete" : "pending", planKeyReady ? "已配置统一 Agent Plan Key,权限待 live doctor 验证" : "尚未配置 Agent Plan Key"), stage("supabase", "连接 Agent Plan Supabase", summary.supabase_data_api && configuration.SUPABASE_WORKSPACE_ID ? "complete" : "pending", diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/start.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/start.mjs index 09ad1de2..3b2fd791 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/start.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/start.mjs @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { spawn } from "node:child_process"; import { - assertInstalledApp, + assertInstalledAppIntegrity, ensureDirectories, liveDoctorEvidence, paths, @@ -15,7 +15,7 @@ import { waitForHealth, } from "./lib.mjs"; -assertInstalledApp(); +assertInstalledAppIntegrity(); ensureDirectories(); const doctor = run(process.execPath, [path.join(paths.skillRoot, "scripts", "doctor.mjs")], { diff --git a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/status.mjs b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/status.mjs index 1ffc6799..ae1324a8 100644 --- a/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/status.mjs +++ b/demohouse/sales-intelligence-workbench/skills/sales-intelligence-workbench/scripts/status.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import { configurationSummary, + installedAppIntegrity, liveDoctorEvidence, paths, processExists, @@ -24,8 +25,10 @@ if (running) { } const evidence = liveDoctorEvidence(); +const integrity = installedAppIntegrity(); process.stdout.write(`${JSON.stringify({ installed: fs.existsSync(paths.installedApp), + integrity, running, pid: running ? pid : null, worker_running: workerRunning, @@ -47,3 +50,4 @@ process.stdout.write(`${JSON.stringify({ worker_log: paths.workerLogFile, }, }, null, 2)}\n`); +if (fs.existsSync(paths.installedApp) && !integrity.ok) process.exitCode = 1;